From c0f58b5a245ff8a13fab71a3123ed0e79974cd37 Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Fri, 20 Feb 2026 16:08:09 -0600 Subject: [PATCH 01/12] AddNServiceBusEndpoint. KeyedServices port and hosting integration --- ...IApprovals.ApproveNServiceBus.approved.txt | 4 + .../HostIntegration/EndpointStarter.cs | 68 ++++++ .../HostApplicationBuilderExtensions.cs | 86 +++++++ .../HostAwareMessageSession.cs | 46 ++++ .../HostIntegration/IEndpointStarter.cs | 14 ++ .../KeyedServiceCollectionAdapter.cs | 210 ++++++++++++++++++ .../KeyedServices/KeyedServiceKey.cs | 48 ++++ .../KeyedServiceProviderAdapter.cs | 204 +++++++++++++++++ .../KeyedServices/KeyedServiceScopeFactory.cs | 40 ++++ .../NServiceBusHostedService.cs | 28 +++ src/NServiceBus.Core/NServiceBus.Core.csproj | 1 + 11 files changed, 749 insertions(+) create mode 100644 src/NServiceBus.Core/HostIntegration/EndpointStarter.cs create mode 100644 src/NServiceBus.Core/HostIntegration/HostApplicationBuilderExtensions.cs create mode 100644 src/NServiceBus.Core/HostIntegration/HostAwareMessageSession.cs create mode 100644 src/NServiceBus.Core/HostIntegration/IEndpointStarter.cs create mode 100644 src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceCollectionAdapter.cs create mode 100644 src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceKey.cs create mode 100644 src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceProviderAdapter.cs create mode 100644 src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceScopeFactory.cs create mode 100644 src/NServiceBus.Core/HostIntegration/NServiceBusHostedService.cs diff --git a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt index 71af4ba545..00f69c31b3 100644 --- a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt +++ b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt @@ -403,6 +403,10 @@ namespace NServiceBus public const string TimeSent = "NServiceBus.TimeSent"; public const string TimeToBeReceived = "NServiceBus.TimeToBeReceived"; } + public static class HostApplicationBuilderExtensions + { + public static void AddNServiceBusEndpoint(this Microsoft.Extensions.Hosting.IHostApplicationBuilder builder, string endpointName, System.Action configure) { } + } public static class HostInfoConfigurationExtensions { public static NServiceBus.HostInfoSettings UniquelyIdentifyRunningInstance(this NServiceBus.EndpointConfiguration config) { } diff --git a/src/NServiceBus.Core/HostIntegration/EndpointStarter.cs b/src/NServiceBus.Core/HostIntegration/EndpointStarter.cs new file mode 100644 index 0000000000..196d8a34d8 --- /dev/null +++ b/src/NServiceBus.Core/HostIntegration/EndpointStarter.cs @@ -0,0 +1,68 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Threading; +using System.Threading.Tasks; + +class EndpointStarter( + IStartableEndpointWithExternallyManagedContainer startableEndpoint, + IServiceProvider serviceProvider, + string serviceKey, + KeyedServiceCollectionAdapter services) : IEndpointStarter +{ + public string ServiceKey => serviceKey; + + public async ValueTask GetOrStart(CancellationToken cancellationToken = default) + { + if (endpoint != null) + { + return endpoint; + } + + await startSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + if (endpoint != null) + { + return endpoint; + } + + keyedServices = new KeyedServiceProviderAdapter(serviceProvider, serviceKey, services); + + endpoint = await startableEndpoint.Start(keyedServices, cancellationToken).ConfigureAwait(false); + + return endpoint; + } + finally + { + startSemaphore.Release(); + } + } + + public async ValueTask DisposeAsync() + { + if (endpoint == null || keyedServices == null) + { + return; + } + + if (endpoint != null) + { + await endpoint.Stop().ConfigureAwait(false); + } + + if (keyedServices != null) + { + await keyedServices.DisposeAsync().ConfigureAwait(false); + } + startSemaphore.Dispose(); + } + + readonly SemaphoreSlim startSemaphore = new(1, 1); + + IEndpointInstance? endpoint; + KeyedServiceProviderAdapter? keyedServices; +} \ No newline at end of file diff --git a/src/NServiceBus.Core/HostIntegration/HostApplicationBuilderExtensions.cs b/src/NServiceBus.Core/HostIntegration/HostApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..d446e11f43 --- /dev/null +++ b/src/NServiceBus.Core/HostIntegration/HostApplicationBuilderExtensions.cs @@ -0,0 +1,86 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Linq; +using System.Runtime.CompilerServices; +using Configuration.AdvancedExtensibility; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Transport; + +/// +/// Extension methods to register NServiceBus endpoints with the host application builder. +/// +public static class HostApplicationBuilderExtensions +{ + /// + /// Registers an NServiceBus endpoint with the specified name. + /// + public static void AddNServiceBusEndpoint( + this IHostApplicationBuilder builder, + string endpointName, + Action configure) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpointName); + ArgumentNullException.ThrowIfNull(configure); + + var endpointKey = $"NServiceBus.Endpoint.{endpointName}"; + if (builder.Properties.ContainsKey(endpointKey)) + { + throw new InvalidOperationException( + $"An endpoint with the name '{endpointName}' has already been registered."); + } + builder.Properties[endpointKey] = true; + + var endpointConfiguration = new EndpointConfiguration(endpointName); + + configure(endpointConfiguration); + + var scanningDisabled = endpointConfiguration.AssemblyScanner().Disable; + var scanningKey = $"NServiceBus.Scanning.{endpointName}"; + builder.Properties[scanningKey] = scanningDisabled; + + var endpointCount = builder.Properties.Keys + .Count(k => k is string s && s.StartsWith("NServiceBus.Endpoint.")); + + if (endpointCount > 1) + { + var endpointsWithScanning = builder.Properties + .Where(kvp => kvp.Key is string s && s.StartsWith("NServiceBus.Scanning.") && kvp.Value is false) + .Select(kvp => ((string)kvp.Key)["NServiceBus.Scanning.".Length..]) + .ToList(); + + if (endpointsWithScanning.Count > 0) + { + throw new InvalidOperationException( + $"When multiple endpoints are registered, each endpoint must disable assembly scanning " + + $"(cfg.AssemblyScanner().Disable = true) and explicitly register its handlers using AddHandler(). " + + $"The following endpoints have assembly scanning enabled: {string.Join(", ", endpointsWithScanning.Select(n => $"'{n}'"))}."); + } + } + + var transport = endpointConfiguration.GetSettings().Get(); + var transportKey = $"NServiceBus.Transport.{RuntimeHelpers.GetHashCode(transport)}"; + if (builder.Properties.TryGetValue(transportKey, out var existingEndpoint)) + { + throw new InvalidOperationException( + $"This transport instance is already used by endpoint '{existingEndpoint}'. Each endpoint requires its own transport instance."); + } + builder.Properties[transportKey] = endpointName; + + var keyedServices = new KeyedServiceCollectionAdapter(builder.Services, endpointName); + var startableEndpoint = EndpointWithExternallyManagedContainer.Create( + endpointConfiguration, keyedServices); + + builder.Services.AddKeyedSingleton(endpointName, (sp, _) => + new EndpointStarter(startableEndpoint, sp, endpointName, keyedServices)); + + builder.Services.AddSingleton(sp => + new NServiceBusHostedService(sp.GetRequiredKeyedService(endpointName))); + + builder.Services.AddKeyedSingleton(endpointName, (sp, key) => + new HostAwareMessageSession(sp.GetRequiredKeyedService(key))); + } +} \ No newline at end of file diff --git a/src/NServiceBus.Core/HostIntegration/HostAwareMessageSession.cs b/src/NServiceBus.Core/HostIntegration/HostAwareMessageSession.cs new file mode 100644 index 0000000000..87407b08bf --- /dev/null +++ b/src/NServiceBus.Core/HostIntegration/HostAwareMessageSession.cs @@ -0,0 +1,46 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Threading; +using System.Threading.Tasks; + +class HostAwareMessageSession(IEndpointStarter endpointStarter) : IMessageSession +{ + public async Task Send(object message, SendOptions options, CancellationToken cancellationToken = default) + { + var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Send(message, options, cancellationToken).ConfigureAwait(false); + } + + public async Task Send(Action messageConstructor, SendOptions options, CancellationToken cancellationToken = default) + { + var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Send(messageConstructor, options, cancellationToken).ConfigureAwait(false); + } + + public async Task Publish(object message, PublishOptions options, CancellationToken cancellationToken = default) + { + var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Publish(message, options, cancellationToken).ConfigureAwait(false); + } + + public async Task Publish(Action messageConstructor, PublishOptions options, CancellationToken cancellationToken = default) + { + var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Publish(messageConstructor, options, cancellationToken).ConfigureAwait(false); + } + + public async Task Subscribe(Type eventType, SubscribeOptions options, CancellationToken cancellationToken = default) + { + var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Subscribe(eventType, options, cancellationToken).ConfigureAwait(false); + } + + public async Task Unsubscribe(Type eventType, UnsubscribeOptions options, CancellationToken cancellationToken = default) + { + var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Unsubscribe(eventType, options, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/NServiceBus.Core/HostIntegration/IEndpointStarter.cs b/src/NServiceBus.Core/HostIntegration/IEndpointStarter.cs new file mode 100644 index 0000000000..7feaf061e7 --- /dev/null +++ b/src/NServiceBus.Core/HostIntegration/IEndpointStarter.cs @@ -0,0 +1,14 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Threading; +using System.Threading.Tasks; + +interface IEndpointStarter : IAsyncDisposable +{ + string ServiceKey { get; } + + ValueTask GetOrStart(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceCollectionAdapter.cs b/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceCollectionAdapter.cs new file mode 100644 index 0000000000..c5cd076347 --- /dev/null +++ b/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceCollectionAdapter.cs @@ -0,0 +1,210 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.DependencyInjection; + +class KeyedServiceCollectionAdapter : IServiceCollection +{ + public KeyedServiceCollectionAdapter(IServiceCollection inner, object serviceKey) + { + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(serviceKey); + + this.inner = inner; + this.serviceKey = new KeyedServiceKey(serviceKey); + } + + public ServiceDescriptor this[int index] + { + get => descriptors[index]; + set => throw new NotSupportedException("Replacing service descriptors is not supported for multi endpoint services."); + } + + public int Count => descriptors.Count; + + public bool IsReadOnly => false; + + public void Add(ServiceDescriptor item) + { + ArgumentNullException.ThrowIfNull(item); + + lock (inner) + { + var keyedDescriptor = EnsureKeyedDescriptor(item); + descriptors.Add(keyedDescriptor); + inner.Add(keyedDescriptor); + } + } + + public void Clear() + { + lock (inner) + { + foreach (var descriptor in descriptors) + { + _ = inner.Remove(descriptor); + } + + descriptors.Clear(); + serviceTypes.Clear(); + } + } + + public bool Contains(ServiceDescriptor item) + { + ArgumentNullException.ThrowIfNull(item); + + return descriptors.Contains(item); + } + + public void CopyTo(ServiceDescriptor[] array, int arrayIndex) => descriptors.CopyTo(array, arrayIndex); + + public IEnumerator GetEnumerator() => descriptors.GetEnumerator(); + + public int IndexOf(ServiceDescriptor item) + { + ArgumentNullException.ThrowIfNull(item); + + return descriptors.IndexOf(item); + } + + public void Insert(int index, ServiceDescriptor item) => throw new NotSupportedException("Inserting service descriptors at specific positions is not supported for multi endpoint services."); + + public bool Remove(ServiceDescriptor item) + { + ArgumentNullException.ThrowIfNull(item); + + lock (inner) + { + if (!descriptors.Remove(item)) + { + return false; + } + + _ = inner.Remove(item); + _ = serviceTypes.Remove(item.ServiceType); + } + return true; + } + + public void RemoveAt(int index) + { + lock (inner) + { + var descriptor = descriptors[index]; + descriptors.RemoveAt(index); + _ = inner.Remove(descriptor); + _ = serviceTypes.Remove(descriptor.ServiceType); + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public bool ContainsService(Type serviceType) + { + ArgumentNullException.ThrowIfNull(serviceType); + + if (serviceTypes.Contains(serviceType)) + { + return true; + } + + if (serviceType.IsGenericType) + { + var definition = serviceType.GetGenericTypeDefinition(); + return serviceTypes.Contains(definition); + } + + return false; + } + + ServiceDescriptor EnsureKeyedDescriptor(ServiceDescriptor descriptor) + { + ServiceDescriptor keyedDescriptor; + if (descriptor.IsKeyedService) + { + if (descriptor.KeyedImplementationInstance is not null) + { + keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, new KeyedServiceKey(serviceKey, descriptor.ServiceKey), descriptor.KeyedImplementationInstance); + } + else if (descriptor.KeyedImplementationFactory is not null) + { + keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, new KeyedServiceKey(serviceKey, descriptor.ServiceKey), (serviceProvider, key) => + { + var resultingKey = key is null ? serviceKey : key as KeyedServiceKey ?? new KeyedServiceKey(key); + var keyedProvider = new KeyedServiceProviderAdapter(serviceProvider, resultingKey, this); + return descriptor.KeyedImplementationFactory!(keyedProvider, key); + }, descriptor.Lifetime); + } + else if (descriptor.KeyedImplementationType is not null) + { + keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, new KeyedServiceKey(serviceKey, descriptor.ServiceKey), + (serviceProvider, key) => + { + var resultingKey = key is null ? serviceKey : key as KeyedServiceKey ?? new KeyedServiceKey(key); + var keyedProvider = new KeyedServiceProviderAdapter(serviceProvider, resultingKey, this); + return descriptor.Lifetime == ServiceLifetime.Singleton ? ActivatorUtilities.CreateInstance(keyedProvider, descriptor.KeyedImplementationType) : + factories.GetOrAdd(descriptor.KeyedImplementationType, type => ActivatorUtilities.CreateFactory(type, Type.EmptyTypes))(keyedProvider, []); + }, descriptor.Lifetime); + UnsafeAccessor.GetImplementationType(keyedDescriptor) = descriptor.KeyedImplementationType; + } + else + { + throw new InvalidOperationException($"Unsupported keyed service descriptor configuration for service type '{descriptor.ServiceType}'."); + } + } + else + { + if (descriptor.ImplementationInstance is not null) + { + keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, serviceKey, descriptor.ImplementationInstance); + } + else if (descriptor.ImplementationFactory is not null) + { + keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, serviceKey, (serviceProvider, key) => + { + var resultingKey = key is null ? serviceKey : key as KeyedServiceKey ?? new KeyedServiceKey(key); + var keyedProvider = new KeyedServiceProviderAdapter(serviceProvider, resultingKey, this); + return descriptor.ImplementationFactory!(keyedProvider); + }, descriptor.Lifetime); + } + else if (descriptor.ImplementationType is not null) + { + keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, serviceKey, + (serviceProvider, key) => + { + var resultingKey = key is null ? serviceKey : key as KeyedServiceKey ?? new KeyedServiceKey(key); + var keyedProvider = new KeyedServiceProviderAdapter(serviceProvider, resultingKey, this); + return descriptor.Lifetime == ServiceLifetime.Singleton ? ActivatorUtilities.CreateInstance(keyedProvider, descriptor.ImplementationType) : + factories.GetOrAdd(descriptor.ImplementationType, type => ActivatorUtilities.CreateFactory(type, Type.EmptyTypes))(keyedProvider, []); + }, descriptor.Lifetime); + UnsafeAccessor.GetImplementationType(keyedDescriptor) = descriptor.ImplementationType; + } + else + { + throw new InvalidOperationException($"Unsupported service descriptor configuration for service type '{descriptor.ServiceType}'."); + } + } + + serviceTypes.Add(keyedDescriptor.ServiceType); + return keyedDescriptor; + } + + static class UnsafeAccessor + { + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_implementationType")] + public static extern ref Type GetImplementationType(ServiceDescriptor descriptor); + } + + readonly IServiceCollection inner; + readonly KeyedServiceKey serviceKey; + readonly List descriptors = []; + readonly HashSet serviceTypes = []; + readonly ConcurrentDictionary factories = new(); +} \ No newline at end of file diff --git a/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceKey.cs b/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceKey.cs new file mode 100644 index 0000000000..9ac3e277dc --- /dev/null +++ b/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceKey.cs @@ -0,0 +1,48 @@ +#nullable enable + +namespace NServiceBus; + +using System; + +sealed class KeyedServiceKey +{ + public KeyedServiceKey(object baseKey, object? serviceKey = null) + { + if (baseKey is KeyedServiceKey key) + { + BaseKey = key.BaseKey; + ServiceKey = key.ServiceKey; + + if (serviceKey is not null) + { + ServiceKey = serviceKey; + } + } + else + { + BaseKey = baseKey; + ServiceKey = serviceKey; + } + } + + public object BaseKey { get; } + + public object? ServiceKey { get; } + + public override bool Equals(object? obj) + { + if (obj is KeyedServiceKey other) + { + return Equals(BaseKey, other.BaseKey) && Equals(ServiceKey, other.ServiceKey); + } + return Equals(BaseKey, obj); + } + + public override int GetHashCode() => ServiceKey == null ? BaseKey.GetHashCode() : HashCode.Combine(BaseKey, ServiceKey); + + public override string? ToString() => ServiceKey == null ? BaseKey.ToString() : $"({BaseKey}, {ServiceKey})"; + + public static KeyedServiceKey AnyKey(object baseKey) => new(baseKey, Any); + + public const string Any = "______________"; +} \ No newline at end of file diff --git a/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceProviderAdapter.cs b/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceProviderAdapter.cs new file mode 100644 index 0000000000..6b8c6b95eb --- /dev/null +++ b/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceProviderAdapter.cs @@ -0,0 +1,204 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +sealed class KeyedServiceProviderAdapter : IKeyedServiceProvider, ISupportRequiredService, IServiceProviderIsKeyedService, IDisposable, IAsyncDisposable +{ + public KeyedServiceProviderAdapter(IServiceProvider serviceProvider, object serviceKey, KeyedServiceCollectionAdapter serviceCollection) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(serviceKey); + ArgumentNullException.ThrowIfNull(serviceCollection); + + this.serviceProvider = serviceProvider; + serviceKeyedServiceKey = new KeyedServiceKey(serviceKey); + anyKey = KeyedServiceKey.AnyKey(serviceKey); + this.serviceCollection = serviceCollection; + + keyedScopeFactory = new KeyedServiceScopeFactory(serviceProvider.GetRequiredService(), serviceKeyedServiceKey, serviceCollection); + } + + public bool IsService(Type serviceType) => serviceCollection.ContainsService(serviceType); + + public bool IsKeyedService(Type serviceType, object? serviceKey) + { + if (!serviceCollection.ContainsService(serviceType)) + { + return false; + } + + if (serviceKey is KeyedServiceKey key) + { + return Equals(serviceKeyedServiceKey.BaseKey, key.BaseKey); + } + + return false; + } + + public object? GetService(Type serviceType) + { + ArgumentNullException.ThrowIfNull(serviceType); + + if (IsServiceProvider(serviceType)) + { + return this; + } + + if (IsScopeFactory(serviceType)) + { + return keyedScopeFactory; + } + + if (!IsServicesRequest(serviceType)) + { + return IsKeyedService(serviceType, serviceKeyedServiceKey) + ? serviceProvider.GetKeyedService(serviceType, serviceKeyedServiceKey) + : serviceProvider.GetService(serviceType); + } + + var itemType = serviceType.GetGenericArguments()[0]; + return IsKeyedService(itemType, serviceKeyedServiceKey) ? serviceProvider.GetKeyedServices(itemType, serviceKeyedServiceKey) : serviceProvider.GetServices(itemType); + } + + public object GetRequiredService(Type serviceType) + { + ArgumentNullException.ThrowIfNull(serviceType); + + if (IsServiceProvider(serviceType)) + { + return this; + } + + if (IsScopeFactory(serviceType)) + { + return keyedScopeFactory; + } + + if (!IsServicesRequest(serviceType)) + { + return IsKeyedService(serviceType, serviceKeyedServiceKey) + ? serviceProvider.GetRequiredKeyedService(serviceType, serviceKeyedServiceKey) + : serviceProvider.GetRequiredService(serviceType); + } + + var itemType = serviceType.GetGenericArguments()[0]; + return IsKeyedService(itemType, serviceKeyedServiceKey) ? serviceProvider.GetKeyedServices(itemType, serviceKeyedServiceKey) : serviceProvider.GetServices(itemType); + } + + public object? GetKeyedService(Type serviceType, object? serviceKey) + { + ArgumentNullException.ThrowIfNull(serviceType); + + if (IsServiceProvider(serviceType)) + { + return this; + } + + if (IsScopeFactory(serviceType)) + { + return keyedScopeFactory; + } + + var computedKey = GetOrCreateComputedKey(serviceKey); + if (!IsServicesRequest(serviceType)) + { + return IsKeyedService(serviceType, computedKey) + ? serviceProvider.GetKeyedService(serviceType, computedKey) + : serviceProvider.GetKeyedService(serviceType, serviceKey); + } + + var itemType = serviceType.GetGenericArguments()[0]; + if (!Equals(computedKey, anyKey)) + { + return IsKeyedService(itemType, computedKey) + ? serviceProvider.GetKeyedServices(itemType, computedKey) + : serviceProvider.GetKeyedServices(itemType, serviceKey); + } + + return GetAllServices(serviceProvider, itemType); + } + + public object GetRequiredKeyedService(Type serviceType, object? serviceKey) + { + ArgumentNullException.ThrowIfNull(serviceType); + + if (IsServiceProvider(serviceType)) + { + return this; + } + + if (IsScopeFactory(serviceType)) + { + return keyedScopeFactory; + } + + var computedKey = GetOrCreateComputedKey(serviceKey); + if (!IsServicesRequest(serviceType)) + { + return IsKeyedService(serviceType, computedKey) + ? serviceProvider.GetRequiredKeyedService(serviceType, computedKey) + : serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); + } + + var itemType = serviceType.GetGenericArguments()[0]; + if (!Equals(computedKey, anyKey)) + { + return IsKeyedService(itemType, computedKey) + ? serviceProvider.GetKeyedServices(itemType, computedKey) + : serviceProvider.GetKeyedServices(itemType, serviceKey); + } + + return GetAllServices(serviceProvider, itemType); + } + + public void Dispose() => (serviceProvider as IDisposable)?.Dispose(); + + public ValueTask DisposeAsync() + { + if (serviceProvider is IAsyncDisposable asyncDisposable) + { + return asyncDisposable.DisposeAsync(); + } + + Dispose(); + return ValueTask.CompletedTask; + } + + KeyedServiceKey GetOrCreateComputedKey(object? serviceKey) + { + if (serviceKey is KeyedServiceKey key && Equals(serviceKeyedServiceKey.BaseKey, key.BaseKey)) + { + return key; + } + + return new KeyedServiceKey(serviceKeyedServiceKey, serviceKey); + } + + static object GetAllServices(IServiceProvider serviceProvider, Type itemType) + { + Type genericEnumerable = typeof(List<>).MakeGenericType(itemType); + var services = (IList)Activator.CreateInstance(genericEnumerable)!; + foreach (var service in serviceProvider.GetServices(itemType).Concat(serviceProvider.GetKeyedServices(itemType, KeyedService.AnyKey))) + { + _ = services.Add(service); + } + return services; + } + + static bool IsServicesRequest(Type serviceType) => serviceType.IsGenericType && serviceType.GetGenericTypeDefinition() == typeof(IEnumerable<>); + static bool IsServiceProvider(Type serviceType) => serviceType == typeof(IServiceProvider) || serviceType == typeof(ISupportRequiredService) || serviceType == typeof(IServiceProviderIsKeyedService) || serviceType == typeof(IServiceProviderIsService); + static bool IsScopeFactory(Type serviceType) => serviceType == typeof(IServiceScopeFactory); + + readonly IServiceProvider serviceProvider; + readonly KeyedServiceKey serviceKeyedServiceKey; + readonly KeyedServiceCollectionAdapter serviceCollection; + readonly KeyedServiceKey anyKey; + readonly KeyedServiceScopeFactory keyedScopeFactory; +} \ No newline at end of file diff --git a/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceScopeFactory.cs b/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceScopeFactory.cs new file mode 100644 index 0000000000..4b67f8f935 --- /dev/null +++ b/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceScopeFactory.cs @@ -0,0 +1,40 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +class KeyedServiceScopeFactory(IServiceScopeFactory innerFactory, object serviceKey, KeyedServiceCollectionAdapter serviceCollection) : IServiceScopeFactory +{ + public IServiceScope CreateScope() + { + var innerScope = innerFactory.CreateScope(); + ArgumentNullException.ThrowIfNull(innerScope); + + return new KeyedServiceScope(innerScope, serviceKey, serviceCollection); + } + + sealed class KeyedServiceScope( + IServiceScope innerScope, + object serviceKey, + KeyedServiceCollectionAdapter serviceCollection) + : IServiceScope, IAsyncDisposable + { + public IServiceProvider ServiceProvider { get; } = new KeyedServiceProviderAdapter(innerScope.ServiceProvider, serviceKey, serviceCollection); + + public void Dispose() => innerScope.Dispose(); + + public ValueTask DisposeAsync() + { + if (innerScope is IAsyncDisposable asyncDisposable) + { + return asyncDisposable.DisposeAsync(); + } + + innerScope.Dispose(); + return ValueTask.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/NServiceBus.Core/HostIntegration/NServiceBusHostedService.cs b/src/NServiceBus.Core/HostIntegration/NServiceBusHostedService.cs new file mode 100644 index 0000000000..da383ef116 --- /dev/null +++ b/src/NServiceBus.Core/HostIntegration/NServiceBusHostedService.cs @@ -0,0 +1,28 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +sealed class NServiceBusHostedService(IEndpointStarter endpointStarter) : IHostedLifecycleService, IAsyncDisposable +{ + public async Task StartingAsync(CancellationToken cancellationToken) => await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); + + public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async ValueTask DisposeAsync() + { + await endpointStarter.DisposeAsync().ConfigureAwait(false); + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/NServiceBus.Core/NServiceBus.Core.csproj b/src/NServiceBus.Core/NServiceBus.Core.csproj index a911741cf0..bfe48a50e2 100644 --- a/src/NServiceBus.Core/NServiceBus.Core.csproj +++ b/src/NServiceBus.Core/NServiceBus.Core.csproj @@ -15,6 +15,7 @@ + From 046a75fe2ab4c65b0ad802977c1cada8625a1a21 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Mon, 23 Feb 2026 15:41:13 +0100 Subject: [PATCH 02/12] Move --- .../{HostIntegration => Hosting}/EndpointStarter.cs | 0 .../HostApplicationBuilderExtensions.cs | 0 .../HostAwareMessageSession.cs | 0 .../{HostIntegration => Hosting}/IEndpointStarter.cs | 0 .../KeyedServices/KeyedServiceCollectionAdapter.cs | 0 .../KeyedServices/KeyedServiceKey.cs | 0 .../KeyedServices/KeyedServiceProviderAdapter.cs | 0 .../KeyedServices/KeyedServiceScopeFactory.cs | 0 .../NServiceBusHostedService.cs | 12 ++++++------ 9 files changed, 6 insertions(+), 6 deletions(-) rename src/NServiceBus.Core/{HostIntegration => Hosting}/EndpointStarter.cs (100%) rename src/NServiceBus.Core/{HostIntegration => Hosting}/HostApplicationBuilderExtensions.cs (100%) rename src/NServiceBus.Core/{HostIntegration => Hosting}/HostAwareMessageSession.cs (100%) rename src/NServiceBus.Core/{HostIntegration => Hosting}/IEndpointStarter.cs (100%) rename src/NServiceBus.Core/{HostIntegration => Hosting}/KeyedServices/KeyedServiceCollectionAdapter.cs (100%) rename src/NServiceBus.Core/{HostIntegration => Hosting}/KeyedServices/KeyedServiceKey.cs (100%) rename src/NServiceBus.Core/{HostIntegration => Hosting}/KeyedServices/KeyedServiceProviderAdapter.cs (100%) rename src/NServiceBus.Core/{HostIntegration => Hosting}/KeyedServices/KeyedServiceScopeFactory.cs (100%) rename src/NServiceBus.Core/{HostIntegration => Hosting}/NServiceBusHostedService.cs (62%) diff --git a/src/NServiceBus.Core/HostIntegration/EndpointStarter.cs b/src/NServiceBus.Core/Hosting/EndpointStarter.cs similarity index 100% rename from src/NServiceBus.Core/HostIntegration/EndpointStarter.cs rename to src/NServiceBus.Core/Hosting/EndpointStarter.cs diff --git a/src/NServiceBus.Core/HostIntegration/HostApplicationBuilderExtensions.cs b/src/NServiceBus.Core/Hosting/HostApplicationBuilderExtensions.cs similarity index 100% rename from src/NServiceBus.Core/HostIntegration/HostApplicationBuilderExtensions.cs rename to src/NServiceBus.Core/Hosting/HostApplicationBuilderExtensions.cs diff --git a/src/NServiceBus.Core/HostIntegration/HostAwareMessageSession.cs b/src/NServiceBus.Core/Hosting/HostAwareMessageSession.cs similarity index 100% rename from src/NServiceBus.Core/HostIntegration/HostAwareMessageSession.cs rename to src/NServiceBus.Core/Hosting/HostAwareMessageSession.cs diff --git a/src/NServiceBus.Core/HostIntegration/IEndpointStarter.cs b/src/NServiceBus.Core/Hosting/IEndpointStarter.cs similarity index 100% rename from src/NServiceBus.Core/HostIntegration/IEndpointStarter.cs rename to src/NServiceBus.Core/Hosting/IEndpointStarter.cs diff --git a/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceCollectionAdapter.cs b/src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceCollectionAdapter.cs similarity index 100% rename from src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceCollectionAdapter.cs rename to src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceCollectionAdapter.cs diff --git a/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceKey.cs b/src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceKey.cs similarity index 100% rename from src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceKey.cs rename to src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceKey.cs diff --git a/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceProviderAdapter.cs b/src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceProviderAdapter.cs similarity index 100% rename from src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceProviderAdapter.cs rename to src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceProviderAdapter.cs diff --git a/src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceScopeFactory.cs b/src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceScopeFactory.cs similarity index 100% rename from src/NServiceBus.Core/HostIntegration/KeyedServices/KeyedServiceScopeFactory.cs rename to src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceScopeFactory.cs diff --git a/src/NServiceBus.Core/HostIntegration/NServiceBusHostedService.cs b/src/NServiceBus.Core/Hosting/NServiceBusHostedService.cs similarity index 62% rename from src/NServiceBus.Core/HostIntegration/NServiceBusHostedService.cs rename to src/NServiceBus.Core/Hosting/NServiceBusHostedService.cs index da383ef116..7ab34b19ac 100644 --- a/src/NServiceBus.Core/HostIntegration/NServiceBusHostedService.cs +++ b/src/NServiceBus.Core/Hosting/NServiceBusHostedService.cs @@ -9,20 +9,20 @@ namespace NServiceBus; sealed class NServiceBusHostedService(IEndpointStarter endpointStarter) : IHostedLifecycleService, IAsyncDisposable { - public async Task StartingAsync(CancellationToken cancellationToken) => await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); + public async Task StartingAsync(CancellationToken cancellationToken = default) => await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); - public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StartedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppingAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public async ValueTask DisposeAsync() { await endpointStarter.DisposeAsync().ConfigureAwait(false); } - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } \ No newline at end of file From cd0924ce6a2bf00b05cfa7d85e10026afdf7ab39 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Mon, 23 Feb 2026 15:48:22 +0100 Subject: [PATCH 03/12] Remove redundant code and KeyedServiceKey public --- .../Support/KeyedServiceCollectionAdapter.cs | 214 ------------------ .../Support/KeyedServiceKey.cs | 46 ---- .../Support/KeyedServiceProviderAdapter.cs | 202 ----------------- .../Support/KeyedServiceScopeFactory.cs | 38 ---- .../Hosting/KeyedServices/KeyedServiceKey.cs | 53 ++++- 5 files changed, 52 insertions(+), 501 deletions(-) delete mode 100644 src/NServiceBus.AcceptanceTesting/Support/KeyedServiceCollectionAdapter.cs delete mode 100644 src/NServiceBus.AcceptanceTesting/Support/KeyedServiceKey.cs delete mode 100644 src/NServiceBus.AcceptanceTesting/Support/KeyedServiceProviderAdapter.cs delete mode 100644 src/NServiceBus.AcceptanceTesting/Support/KeyedServiceScopeFactory.cs diff --git a/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceCollectionAdapter.cs b/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceCollectionAdapter.cs deleted file mode 100644 index d25548b636..0000000000 --- a/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceCollectionAdapter.cs +++ /dev/null @@ -1,214 +0,0 @@ -namespace NServiceBus.AcceptanceTesting.Support; - -using System; -using System.Collections; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using Microsoft.Extensions.DependencyInjection; - -class KeyedServiceCollectionAdapter : IServiceCollection -{ - public KeyedServiceCollectionAdapter(IServiceCollection inner, object serviceKey) - { - ArgumentNullException.ThrowIfNull(inner); - ArgumentNullException.ThrowIfNull(serviceKey); - - this.inner = inner; - this.serviceKey = new KeyedServiceKey(serviceKey); - } - - public ServiceDescriptor this[int index] - { - // we assume no more modifications can occur at this point and therefore read without a lock - get => descriptors[index]; - set => throw new NotSupportedException("Replacing service descriptors is not supported for multi endpoint services."); - } - - public int Count => descriptors.Count; - - public bool IsReadOnly => false; - - public void Add(ServiceDescriptor item) - { - ArgumentNullException.ThrowIfNull(item); - - lock (inner) - { - var keyedDescriptor = EnsureKeyedDescriptor(item); - descriptors.Add(keyedDescriptor); - inner.Add(keyedDescriptor); - } - } - - public void Clear() - { - lock (inner) - { - foreach (var descriptor in descriptors) - { - _ = inner.Remove(descriptor); - } - - descriptors.Clear(); - serviceTypes.Clear(); - } - } - - public bool Contains(ServiceDescriptor item) - { - ArgumentNullException.ThrowIfNull(item); - - // we assume no more modifications can occur at this point and therefore read without a lock - return descriptors.Contains(item); - } - - public void CopyTo(ServiceDescriptor[] array, int arrayIndex) => descriptors.CopyTo(array, arrayIndex); - - public IEnumerator GetEnumerator() => descriptors.GetEnumerator(); // we assume no more modifications can occur at this point and therefore read without a lock - - public int IndexOf(ServiceDescriptor item) - { - ArgumentNullException.ThrowIfNull(item); - - // we assume no more modifications can occur at this point and therefore read without a lock - return descriptors.IndexOf(item); - } - - public void Insert(int index, ServiceDescriptor item) => throw new NotSupportedException("Inserting service descriptors at specific positions is not supported for multi endpoint services."); - - public bool Remove(ServiceDescriptor item) - { - ArgumentNullException.ThrowIfNull(item); - - lock (inner) - { - if (!descriptors.Remove(item)) - { - return false; - } - - _ = inner.Remove(item); - _ = serviceTypes.Remove(item.ServiceType); - } - return true; - } - - public void RemoveAt(int index) - { - lock (inner) - { - var descriptor = descriptors[index]; - descriptors.RemoveAt(index); - _ = inner.Remove(descriptor); - _ = serviceTypes.Remove(descriptor.ServiceType); - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - public bool ContainsService(Type serviceType) - { - ArgumentNullException.ThrowIfNull(serviceType); - - // we assume no more modifications can occur at this point and therefore read without a lock - if (serviceTypes.Contains(serviceType)) - { - return true; - } - - if (serviceType.IsGenericType) - { - var definition = serviceType.GetGenericTypeDefinition(); - return serviceTypes.Contains(definition); - } - - return false; - } - - ServiceDescriptor EnsureKeyedDescriptor(ServiceDescriptor descriptor) - { - ServiceDescriptor keyedDescriptor; - if (descriptor.IsKeyedService) - { - if (descriptor.KeyedImplementationInstance is not null) - { - keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, new KeyedServiceKey(serviceKey, descriptor.ServiceKey), descriptor.KeyedImplementationInstance); - } - else if (descriptor.KeyedImplementationFactory is not null) - { - keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, new KeyedServiceKey(serviceKey, descriptor.ServiceKey), (serviceProvider, key) => - { - var resultingKey = key is null ? serviceKey : key as KeyedServiceKey ?? new KeyedServiceKey(key); - var keyedProvider = new KeyedServiceProviderAdapter(serviceProvider, resultingKey, this); - return descriptor.KeyedImplementationFactory!(keyedProvider, key); - }, descriptor.Lifetime); - } - else if (descriptor.KeyedImplementationType is not null) - { - keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, new KeyedServiceKey(serviceKey, descriptor.ServiceKey), - (serviceProvider, key) => - { - var resultingKey = key is null ? serviceKey : key as KeyedServiceKey ?? new KeyedServiceKey(key); - var keyedProvider = new KeyedServiceProviderAdapter(serviceProvider, resultingKey, this); - return descriptor.Lifetime == ServiceLifetime.Singleton ? ActivatorUtilities.CreateInstance(keyedProvider, descriptor.KeyedImplementationType) : - factories.GetOrAdd(descriptor.KeyedImplementationType, type => ActivatorUtilities.CreateFactory(type, Type.EmptyTypes))(keyedProvider, []); - }, descriptor.Lifetime); - // Crazy hack to work around generic constraint checks - UnsafeAccessor.GetImplementationType(keyedDescriptor) = descriptor.KeyedImplementationType; - } - else - { - throw new InvalidOperationException($"Unsupported keyed service descriptor configuration for service type '{descriptor.ServiceType}'."); - } - } - else - { - if (descriptor.ImplementationInstance is not null) - { - keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, serviceKey, descriptor.ImplementationInstance); - } - else if (descriptor.ImplementationFactory is not null) - { - keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, serviceKey, (serviceProvider, key) => - { - var resultingKey = key is null ? serviceKey : key as KeyedServiceKey ?? new KeyedServiceKey(key); - var keyedProvider = new KeyedServiceProviderAdapter(serviceProvider, resultingKey, this); - return descriptor.ImplementationFactory!(keyedProvider); - }, descriptor.Lifetime); - } - else if (descriptor.ImplementationType is not null) - { - keyedDescriptor = new ServiceDescriptor(descriptor.ServiceType, serviceKey, - (serviceProvider, key) => - { - var resultingKey = key is null ? serviceKey : key as KeyedServiceKey ?? new KeyedServiceKey(key); - var keyedProvider = new KeyedServiceProviderAdapter(serviceProvider, resultingKey, this); - return descriptor.Lifetime == ServiceLifetime.Singleton ? ActivatorUtilities.CreateInstance(keyedProvider, descriptor.ImplementationType) : - factories.GetOrAdd(descriptor.ImplementationType, type => ActivatorUtilities.CreateFactory(type, Type.EmptyTypes))(keyedProvider, []); - }, descriptor.Lifetime); - // Crazy hack to work around generic constraint checks - UnsafeAccessor.GetImplementationType(keyedDescriptor) = descriptor.ImplementationType; - } - else - { - throw new InvalidOperationException($"Unsupported service descriptor configuration for service type '{descriptor.ServiceType}'."); - } - } - - serviceTypes.Add(keyedDescriptor.ServiceType); - return keyedDescriptor; - } - - static class UnsafeAccessor - { - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_implementationType")] - public static extern ref Type GetImplementationType(ServiceDescriptor descriptor); - } - - readonly IServiceCollection inner; - readonly KeyedServiceKey serviceKey; - readonly List descriptors = []; - readonly HashSet serviceTypes = []; - readonly ConcurrentDictionary factories = new(); -} \ No newline at end of file diff --git a/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceKey.cs b/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceKey.cs deleted file mode 100644 index 2868ded24f..0000000000 --- a/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceKey.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace NServiceBus.AcceptanceTesting.Support; - -using System; - -public sealed class KeyedServiceKey -{ - public KeyedServiceKey(object baseKey, object? serviceKey = null) - { - if (baseKey is KeyedServiceKey key) - { - BaseKey = key.BaseKey; - ServiceKey = key.ServiceKey; - - if (serviceKey is not null) - { - ServiceKey = serviceKey; - } - } - else - { - BaseKey = baseKey; - ServiceKey = serviceKey; - } - } - - public object BaseKey { get; } - - public object? ServiceKey { get; } - - public override bool Equals(object? obj) - { - if (obj is KeyedServiceKey other) - { - return Equals(BaseKey, other.BaseKey) && Equals(ServiceKey, other.ServiceKey); - } - return Equals(BaseKey, obj); - } - - public override int GetHashCode() => ServiceKey == null ? BaseKey.GetHashCode() : HashCode.Combine(BaseKey, ServiceKey); - - public override string? ToString() => ServiceKey == null ? BaseKey.ToString() : $"({BaseKey}, {ServiceKey})"; - - public static KeyedServiceKey AnyKey(object baseKey) => new(baseKey, Any); - - public const string Any = "______________"; -} \ No newline at end of file diff --git a/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceProviderAdapter.cs b/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceProviderAdapter.cs deleted file mode 100644 index 1395fe637e..0000000000 --- a/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceProviderAdapter.cs +++ /dev/null @@ -1,202 +0,0 @@ -namespace NServiceBus.AcceptanceTesting.Support; - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; - -sealed class KeyedServiceProviderAdapter : IKeyedServiceProvider, ISupportRequiredService, IServiceProviderIsKeyedService, IDisposable, IAsyncDisposable -{ - public KeyedServiceProviderAdapter(IServiceProvider serviceProvider, object serviceKey, KeyedServiceCollectionAdapter serviceCollection) - { - ArgumentNullException.ThrowIfNull(serviceProvider); - ArgumentNullException.ThrowIfNull(serviceKey); - ArgumentNullException.ThrowIfNull(serviceCollection); - - this.serviceProvider = serviceProvider; - serviceKeyedServiceKey = new KeyedServiceKey(serviceKey); - anyKey = KeyedServiceKey.AnyKey(serviceKey); - this.serviceCollection = serviceCollection; - - keyedScopeFactory = new KeyedServiceScopeFactory(serviceProvider.GetRequiredService(), serviceKeyedServiceKey, serviceCollection); - } - - public bool IsService(Type serviceType) => serviceCollection.ContainsService(serviceType); - - public bool IsKeyedService(Type serviceType, object? serviceKey) - { - if (!serviceCollection.ContainsService(serviceType)) - { - return false; - } - - if (serviceKey is KeyedServiceKey key) - { - return Equals(serviceKeyedServiceKey.BaseKey, key.BaseKey); - } - - return false; - } - - public object? GetService(Type serviceType) - { - ArgumentNullException.ThrowIfNull(serviceType); - - if (IsServiceProvider(serviceType)) - { - return this; - } - - if (IsScopeFactory(serviceType)) - { - return keyedScopeFactory; - } - - if (!IsServicesRequest(serviceType)) - { - return IsKeyedService(serviceType, serviceKeyedServiceKey) - ? serviceProvider.GetKeyedService(serviceType, serviceKeyedServiceKey) - : serviceProvider.GetService(serviceType); - } - - var itemType = serviceType.GetGenericArguments()[0]; - return IsKeyedService(itemType, serviceKeyedServiceKey) ? serviceProvider.GetKeyedServices(itemType, serviceKeyedServiceKey) : serviceProvider.GetServices(itemType); - } - - public object GetRequiredService(Type serviceType) - { - ArgumentNullException.ThrowIfNull(serviceType); - - if (IsServiceProvider(serviceType)) - { - return this; - } - - if (IsScopeFactory(serviceType)) - { - return keyedScopeFactory; - } - - if (!IsServicesRequest(serviceType)) - { - return IsKeyedService(serviceType, serviceKeyedServiceKey) - ? serviceProvider.GetRequiredKeyedService(serviceType, serviceKeyedServiceKey) - : serviceProvider.GetRequiredService(serviceType); - } - - var itemType = serviceType.GetGenericArguments()[0]; - return IsKeyedService(itemType, serviceKeyedServiceKey) ? serviceProvider.GetKeyedServices(itemType, serviceKeyedServiceKey) : serviceProvider.GetServices(itemType); - } - - public object? GetKeyedService(Type serviceType, object? serviceKey) - { - ArgumentNullException.ThrowIfNull(serviceType); - - if (IsServiceProvider(serviceType)) - { - return this; - } - - if (IsScopeFactory(serviceType)) - { - return keyedScopeFactory; - } - - var computedKey = GetOrCreateComputedKey(serviceKey); - if (!IsServicesRequest(serviceType)) - { - return IsKeyedService(serviceType, computedKey) - ? serviceProvider.GetKeyedService(serviceType, computedKey) - : serviceProvider.GetKeyedService(serviceType, serviceKey); - } - - var itemType = serviceType.GetGenericArguments()[0]; - if (!Equals(computedKey, anyKey)) - { - return IsKeyedService(itemType, computedKey) - ? serviceProvider.GetKeyedServices(itemType, computedKey) - : serviceProvider.GetKeyedServices(itemType, serviceKey); - } - - return GetAllServices(serviceProvider, itemType); - } - - public object GetRequiredKeyedService(Type serviceType, object? serviceKey) - { - ArgumentNullException.ThrowIfNull(serviceType); - - if (IsServiceProvider(serviceType)) - { - return this; - } - - if (IsScopeFactory(serviceType)) - { - return keyedScopeFactory; - } - - var computedKey = GetOrCreateComputedKey(serviceKey); - if (!IsServicesRequest(serviceType)) - { - return IsKeyedService(serviceType, computedKey) - ? serviceProvider.GetRequiredKeyedService(serviceType, computedKey) - : serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); - } - - var itemType = serviceType.GetGenericArguments()[0]; - if (!Equals(computedKey, anyKey)) - { - return IsKeyedService(itemType, computedKey) - ? serviceProvider.GetKeyedServices(itemType, computedKey) - : serviceProvider.GetKeyedServices(itemType, serviceKey); - } - - return GetAllServices(serviceProvider, itemType); - } - - public void Dispose() => (serviceProvider as IDisposable)?.Dispose(); - - public ValueTask DisposeAsync() - { - if (serviceProvider is IAsyncDisposable asyncDisposable) - { - return asyncDisposable.DisposeAsync(); - } - - Dispose(); - return ValueTask.CompletedTask; - } - - KeyedServiceKey GetOrCreateComputedKey(object? serviceKey) - { - if (serviceKey is KeyedServiceKey key && Equals(serviceKeyedServiceKey.BaseKey, key.BaseKey)) - { - return key; - } - - return new KeyedServiceKey(serviceKeyedServiceKey, serviceKey); - } - - static object GetAllServices(IServiceProvider serviceProvider, Type itemType) - { - Type genericEnumerable = typeof(List<>).MakeGenericType(itemType); - var services = (IList)Activator.CreateInstance(genericEnumerable)!; - foreach (var service in serviceProvider.GetServices(itemType).Concat(serviceProvider.GetKeyedServices(itemType, KeyedService.AnyKey))) - { - _ = services.Add(service); - } - return services; - } - - static bool IsServicesRequest(Type serviceType) => serviceType.IsGenericType && serviceType.GetGenericTypeDefinition() == typeof(IEnumerable<>); - static bool IsServiceProvider(Type serviceType) => serviceType == typeof(IServiceProvider) || serviceType == typeof(ISupportRequiredService) || serviceType == typeof(IServiceProviderIsKeyedService) || serviceType == typeof(IServiceProviderIsService); - static bool IsScopeFactory(Type serviceType) => serviceType == typeof(IServiceScopeFactory); - - readonly IServiceProvider serviceProvider; - readonly KeyedServiceKey serviceKeyedServiceKey; - readonly KeyedServiceCollectionAdapter serviceCollection; - readonly KeyedServiceKey anyKey; - readonly KeyedServiceScopeFactory keyedScopeFactory; -} \ No newline at end of file diff --git a/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceScopeFactory.cs b/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceScopeFactory.cs deleted file mode 100644 index 1d2f5fa4e3..0000000000 --- a/src/NServiceBus.AcceptanceTesting/Support/KeyedServiceScopeFactory.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace NServiceBus.AcceptanceTesting.Support; - -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; - -class KeyedServiceScopeFactory(IServiceScopeFactory innerFactory, object serviceKey, KeyedServiceCollectionAdapter serviceCollection) : IServiceScopeFactory -{ - public IServiceScope CreateScope() - { - var innerScope = innerFactory.CreateScope(); - ArgumentNullException.ThrowIfNull(innerScope); - - return new KeyedServiceScope(innerScope, serviceKey, serviceCollection); - } - - sealed class KeyedServiceScope( - IServiceScope innerScope, - object serviceKey, - KeyedServiceCollectionAdapter serviceCollection) - : IServiceScope, IAsyncDisposable - { - public IServiceProvider ServiceProvider { get; } = new KeyedServiceProviderAdapter(innerScope.ServiceProvider, serviceKey, serviceCollection); - - public void Dispose() => innerScope.Dispose(); - - public ValueTask DisposeAsync() - { - if (innerScope is IAsyncDisposable asyncDisposable) - { - return asyncDisposable.DisposeAsync(); - } - - innerScope.Dispose(); - return ValueTask.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceKey.cs b/src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceKey.cs index 9ac3e277dc..c4bbf720ff 100644 --- a/src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceKey.cs +++ b/src/NServiceBus.Core/Hosting/KeyedServices/KeyedServiceKey.cs @@ -4,8 +4,16 @@ namespace NServiceBus; using System; -sealed class KeyedServiceKey +/// +/// Represents a composite key used for resolving services in a keyed service collection, +/// combining a base key with an optional service-specific key. +/// +public sealed class KeyedServiceKey { + /// + /// Represents a composite key used for resolving services in a keyed service collection. + /// Combines a base key with an optional service-specific key. + /// public KeyedServiceKey(object baseKey, object? serviceKey = null) { if (baseKey is KeyedServiceKey key) @@ -25,10 +33,26 @@ public KeyedServiceKey(object baseKey, object? serviceKey = null) } } + /// + /// Gets the base key component of the composite key, which is used to identify a service + /// in a keyed service collection. This value is mandatory and serves as the primary + /// identifier in the composite key structure. + /// public object BaseKey { get; } + /// + /// Gets the service-specific key component of the composite key, which is optional and used to + /// further differentiate services within the same base key in a keyed service collection. + /// public object? ServiceKey { get; } + /// + /// Determines whether the specified object is equal to the current instance of the KeyedServiceKey. + /// + /// The object to compare with the current KeyedServiceKey, or null. + /// + /// true if the specified object is equal to the current KeyedServiceKey; otherwise, false. + /// public override bool Equals(object? obj) { if (obj is KeyedServiceKey other) @@ -38,11 +62,38 @@ public override bool Equals(object? obj) return Equals(BaseKey, obj); } + /// + /// Returns a hash code for the current instance of the KeyedServiceKey. + /// Combines the hash code of the base key and, if present, the service-specific key. + /// + /// + /// An integer representing the hash code of the current KeyedServiceKey instance. + /// public override int GetHashCode() => ServiceKey == null ? BaseKey.GetHashCode() : HashCode.Combine(BaseKey, ServiceKey); + /// + /// Returns a string representation of the current KeyedServiceKey instance. + /// If the service-specific key is not present, returns the string representation + /// of the base key. Otherwise, returns a composite string representation of both + /// the base key and the service-specific key. + /// + /// + /// A string representation of the current instance, including both the base key + /// and the service-specific key, if present. + /// public override string? ToString() => ServiceKey == null ? BaseKey.ToString() : $"({BaseKey}, {ServiceKey})"; + /// + /// Creates a new instance of the with the specified base key + /// and a predefined value indicating a wildcard key. + /// + /// The base key to use for the composite service key. + /// A representing the wildcard configuration with the provided base key. public static KeyedServiceKey AnyKey(object baseKey) => new(baseKey, Any); + /// + /// Represents a constant wildcard value used in to signify a match against + /// any service-specific key within the keyed service collection. + /// public const string Any = "______________"; } \ No newline at end of file From 11402e833ee2095da591f365536f5087bc3172a5 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 24 Feb 2026 22:10:46 +0100 Subject: [PATCH 04/12] Switch to service collection --- ...IApprovals.ApproveNServiceBus.approved.txt | 10 +- .../Hosting/EndpointStarter.cs | 4 +- .../HostApplicationBuilderExtensions.cs | 86 ----------- .../Hosting/IEndpointStarter.cs | 2 - .../Hosting/ServiceCollectionExtensions.cs | 138 ++++++++++++++++++ .../Hosting/UnkeyedEndpointStarter.cs | 53 +++++++ 6 files changed, 197 insertions(+), 96 deletions(-) delete mode 100644 src/NServiceBus.Core/Hosting/HostApplicationBuilderExtensions.cs create mode 100644 src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs create mode 100644 src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs diff --git a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt index 00f69c31b3..ce62d93400 100644 --- a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt +++ b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt @@ -403,10 +403,6 @@ namespace NServiceBus public const string TimeSent = "NServiceBus.TimeSent"; public const string TimeToBeReceived = "NServiceBus.TimeToBeReceived"; } - public static class HostApplicationBuilderExtensions - { - public static void AddNServiceBusEndpoint(this Microsoft.Extensions.Hosting.IHostApplicationBuilder builder, string endpointName, System.Action configure) { } - } public static class HostInfoConfigurationExtensions { public static NServiceBus.HostInfoSettings UniquelyIdentifyRunningInstance(this NServiceBus.EndpointConfiguration config) { } @@ -1007,6 +1003,10 @@ namespace NServiceBus public static void DisableMessageTypeInference(this NServiceBus.Serialization.SerializationExtensions config) where T : NServiceBus.Serialization.SerializationDefinition { } } + public static class ServiceCollectionExtensions + { + public static void AddNServiceBusEndpoint(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, NServiceBus.EndpointConfiguration endpointConfiguration, object? endpointIdentifier = null) { } + } public static class SettingsExtensions { public static string EndpointName(this NServiceBus.Settings.IReadOnlySettings settings) { } @@ -2637,4 +2637,4 @@ namespace NServiceBus.Unicast.Transport { public static NServiceBus.Transport.OutgoingMessage Create(NServiceBus.MessageIntent intent) { } } -} \ No newline at end of file +} diff --git a/src/NServiceBus.Core/Hosting/EndpointStarter.cs b/src/NServiceBus.Core/Hosting/EndpointStarter.cs index 196d8a34d8..7bc56b0235 100644 --- a/src/NServiceBus.Core/Hosting/EndpointStarter.cs +++ b/src/NServiceBus.Core/Hosting/EndpointStarter.cs @@ -9,11 +9,9 @@ namespace NServiceBus; class EndpointStarter( IStartableEndpointWithExternallyManagedContainer startableEndpoint, IServiceProvider serviceProvider, - string serviceKey, + object serviceKey, KeyedServiceCollectionAdapter services) : IEndpointStarter { - public string ServiceKey => serviceKey; - public async ValueTask GetOrStart(CancellationToken cancellationToken = default) { if (endpoint != null) diff --git a/src/NServiceBus.Core/Hosting/HostApplicationBuilderExtensions.cs b/src/NServiceBus.Core/Hosting/HostApplicationBuilderExtensions.cs deleted file mode 100644 index d446e11f43..0000000000 --- a/src/NServiceBus.Core/Hosting/HostApplicationBuilderExtensions.cs +++ /dev/null @@ -1,86 +0,0 @@ -#nullable enable - -namespace NServiceBus; - -using System; -using System.Linq; -using System.Runtime.CompilerServices; -using Configuration.AdvancedExtensibility; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Transport; - -/// -/// Extension methods to register NServiceBus endpoints with the host application builder. -/// -public static class HostApplicationBuilderExtensions -{ - /// - /// Registers an NServiceBus endpoint with the specified name. - /// - public static void AddNServiceBusEndpoint( - this IHostApplicationBuilder builder, - string endpointName, - Action configure) - { - ArgumentException.ThrowIfNullOrWhiteSpace(endpointName); - ArgumentNullException.ThrowIfNull(configure); - - var endpointKey = $"NServiceBus.Endpoint.{endpointName}"; - if (builder.Properties.ContainsKey(endpointKey)) - { - throw new InvalidOperationException( - $"An endpoint with the name '{endpointName}' has already been registered."); - } - builder.Properties[endpointKey] = true; - - var endpointConfiguration = new EndpointConfiguration(endpointName); - - configure(endpointConfiguration); - - var scanningDisabled = endpointConfiguration.AssemblyScanner().Disable; - var scanningKey = $"NServiceBus.Scanning.{endpointName}"; - builder.Properties[scanningKey] = scanningDisabled; - - var endpointCount = builder.Properties.Keys - .Count(k => k is string s && s.StartsWith("NServiceBus.Endpoint.")); - - if (endpointCount > 1) - { - var endpointsWithScanning = builder.Properties - .Where(kvp => kvp.Key is string s && s.StartsWith("NServiceBus.Scanning.") && kvp.Value is false) - .Select(kvp => ((string)kvp.Key)["NServiceBus.Scanning.".Length..]) - .ToList(); - - if (endpointsWithScanning.Count > 0) - { - throw new InvalidOperationException( - $"When multiple endpoints are registered, each endpoint must disable assembly scanning " + - $"(cfg.AssemblyScanner().Disable = true) and explicitly register its handlers using AddHandler(). " + - $"The following endpoints have assembly scanning enabled: {string.Join(", ", endpointsWithScanning.Select(n => $"'{n}'"))}."); - } - } - - var transport = endpointConfiguration.GetSettings().Get(); - var transportKey = $"NServiceBus.Transport.{RuntimeHelpers.GetHashCode(transport)}"; - if (builder.Properties.TryGetValue(transportKey, out var existingEndpoint)) - { - throw new InvalidOperationException( - $"This transport instance is already used by endpoint '{existingEndpoint}'. Each endpoint requires its own transport instance."); - } - builder.Properties[transportKey] = endpointName; - - var keyedServices = new KeyedServiceCollectionAdapter(builder.Services, endpointName); - var startableEndpoint = EndpointWithExternallyManagedContainer.Create( - endpointConfiguration, keyedServices); - - builder.Services.AddKeyedSingleton(endpointName, (sp, _) => - new EndpointStarter(startableEndpoint, sp, endpointName, keyedServices)); - - builder.Services.AddSingleton(sp => - new NServiceBusHostedService(sp.GetRequiredKeyedService(endpointName))); - - builder.Services.AddKeyedSingleton(endpointName, (sp, key) => - new HostAwareMessageSession(sp.GetRequiredKeyedService(key))); - } -} \ No newline at end of file diff --git a/src/NServiceBus.Core/Hosting/IEndpointStarter.cs b/src/NServiceBus.Core/Hosting/IEndpointStarter.cs index 7feaf061e7..dccff8d01b 100644 --- a/src/NServiceBus.Core/Hosting/IEndpointStarter.cs +++ b/src/NServiceBus.Core/Hosting/IEndpointStarter.cs @@ -8,7 +8,5 @@ namespace NServiceBus; interface IEndpointStarter : IAsyncDisposable { - string ServiceKey { get; } - ValueTask GetOrStart(CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..7936d483a3 --- /dev/null +++ b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs @@ -0,0 +1,138 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using Configuration.AdvancedExtensibility; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Transport; + +/// +/// Extension methods to register NServiceBus endpoints with the service collection. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers an NServiceBus endpoint. + /// + public static void AddNServiceBusEndpoint( + this IServiceCollection services, + EndpointConfiguration endpointConfiguration, + object? endpointIdentifier = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(endpointConfiguration); + + var endpointName = endpointConfiguration.GetSettings().EndpointName(); + var transport = endpointConfiguration.GetSettings().Get(); + var registrations = GetExistingRegistrations(services); + + ValidateEndpointName(endpointName, registrations); + ValidateEndpointIdentifier(endpointIdentifier, registrations); + ValidateAssemblyScanning(endpointConfiguration, endpointName, registrations); + ValidateTransportReuse(transport, registrations); + + if (endpointIdentifier is null) + { + var startableEndpoint = EndpointWithExternallyManagedContainer.Create(endpointConfiguration, services); + + services.AddSingleton(sp => new UnkeyedEndpointStarter(startableEndpoint, sp)); + services.AddSingleton(sp => + new NServiceBusHostedService(sp.GetRequiredService())); + services.AddSingleton(sp => + new HostAwareMessageSession(sp.GetRequiredService())); + } + else + { + var keyedServices = new KeyedServiceCollectionAdapter(services, endpointIdentifier); + var startableEndpoint = EndpointWithExternallyManagedContainer.Create(endpointConfiguration, keyedServices); + + services.AddKeyedSingleton(endpointIdentifier, (sp, _) => + new EndpointStarter(startableEndpoint, sp, endpointIdentifier, keyedServices)); + + services.AddSingleton(sp => + new NServiceBusHostedService(sp.GetRequiredKeyedService(endpointIdentifier))); + + services.AddKeyedSingleton(endpointIdentifier, (sp, key) => + new HostAwareMessageSession(sp.GetRequiredKeyedService(key!))); + } + + services.AddSingleton(new EndpointRegistration(endpointName, endpointIdentifier, endpointConfiguration.AssemblyScanner().Disable, RuntimeHelpers.GetHashCode(transport))); + } + + static void ValidateEndpointName(string endpointName, List registrations) + { + if (registrations.Any(r => r.EndpointName == endpointName)) + { + throw new InvalidOperationException( + $"An endpoint with the name '{endpointName}' has already been registered."); + } + } + + static void ValidateEndpointIdentifier(object? endpointIdentifier, List registrations) + { + if (registrations.Count == 0) + { + return; + } + + if (endpointIdentifier is null || registrations.Any(r => r.EndpointIdentifier is null)) + { + throw new InvalidOperationException( + "When multiple endpoints are registered, each endpoint must provide an endpointIdentifier."); + } + + if (registrations.Any(r => Equals(r.EndpointIdentifier, endpointIdentifier))) + { + throw new InvalidOperationException( + $"An endpoint with the identifier '{endpointIdentifier}' has already been registered."); + } + } + + static void ValidateAssemblyScanning(EndpointConfiguration endpointConfiguration, string endpointName, List registrations) + { + var endpoints = registrations + .Append(new EndpointRegistration(endpointName, null, endpointConfiguration.AssemblyScanner().Disable, 0)) + .ToList(); + + if (endpoints.Count <= 1) + { + return; + } + + var endpointsWithScanning = endpoints + .Where(r => !r.ScanningDisabled) + .Select(r => r.EndpointName) + .ToList(); + + if (endpointsWithScanning.Count > 0) + { + throw new InvalidOperationException( + $"When multiple endpoints are registered, each endpoint must disable assembly scanning " + + $"(cfg.AssemblyScanner().Disable = true) and explicitly register its handlers using AddHandler(). " + + $"The following endpoints have assembly scanning enabled: {string.Join(", ", endpointsWithScanning.Select(n => $"'{n}'"))}."); + } + } + + static void ValidateTransportReuse(TransportDefinition transport, List registrations) + { + var transportHash = RuntimeHelpers.GetHashCode(transport); + var existingRegistration = registrations.FirstOrDefault(r => r.TransportHashCode == transportHash); + if (existingRegistration is not null) + { + throw new InvalidOperationException( + $"This transport instance is already used by endpoint '{existingRegistration.EndpointName}'. Each endpoint requires its own transport instance."); + } + } + + static List GetExistingRegistrations(IServiceCollection services) => + [.. services + .Where(d => d.ServiceType == typeof(EndpointRegistration) && d.ImplementationInstance is EndpointRegistration) + .Select(d => (EndpointRegistration)d.ImplementationInstance!)]; + + sealed record EndpointRegistration(string EndpointName, object? EndpointIdentifier, bool ScanningDisabled, int TransportHashCode); +} \ No newline at end of file diff --git a/src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs b/src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs new file mode 100644 index 0000000000..82d1a9072b --- /dev/null +++ b/src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs @@ -0,0 +1,53 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Threading; +using System.Threading.Tasks; + +sealed class UnkeyedEndpointStarter( + IStartableEndpointWithExternallyManagedContainer startableEndpoint, + IServiceProvider serviceProvider) : IEndpointStarter +{ + public async ValueTask GetOrStart(CancellationToken cancellationToken = default) + { + if (endpoint != null) + { + return endpoint; + } + + await startSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + if (endpoint != null) + { + return endpoint; + } + + endpoint = await startableEndpoint.Start(serviceProvider, cancellationToken).ConfigureAwait(false); + + return endpoint; + } + finally + { + startSemaphore.Release(); + } + } + + public async ValueTask DisposeAsync() + { + if (endpoint == null) + { + return; + } + + await endpoint.Stop().ConfigureAwait(false); + startSemaphore.Dispose(); + } + + readonly SemaphoreSlim startSemaphore = new(1, 1); + + IEndpointInstance? endpoint; +} \ No newline at end of file From 0e91da27662840d7157eea1fa23f29b90af9ff09 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 24 Feb 2026 22:22:28 +0100 Subject: [PATCH 05/12] Refactor `IEndpointStarter` to implement `IMessageSession` directly, removing `HostAwareMessageSession`. --- .../Hosting/HostAwareMessageSession.cs | 46 ------------------- .../Hosting/IEndpointStarter.cs | 38 ++++++++++++++- .../Hosting/ServiceCollectionExtensions.cs | 5 +- 3 files changed, 39 insertions(+), 50 deletions(-) delete mode 100644 src/NServiceBus.Core/Hosting/HostAwareMessageSession.cs diff --git a/src/NServiceBus.Core/Hosting/HostAwareMessageSession.cs b/src/NServiceBus.Core/Hosting/HostAwareMessageSession.cs deleted file mode 100644 index 87407b08bf..0000000000 --- a/src/NServiceBus.Core/Hosting/HostAwareMessageSession.cs +++ /dev/null @@ -1,46 +0,0 @@ -#nullable enable - -namespace NServiceBus; - -using System; -using System.Threading; -using System.Threading.Tasks; - -class HostAwareMessageSession(IEndpointStarter endpointStarter) : IMessageSession -{ - public async Task Send(object message, SendOptions options, CancellationToken cancellationToken = default) - { - var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); - await messageSession.Send(message, options, cancellationToken).ConfigureAwait(false); - } - - public async Task Send(Action messageConstructor, SendOptions options, CancellationToken cancellationToken = default) - { - var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); - await messageSession.Send(messageConstructor, options, cancellationToken).ConfigureAwait(false); - } - - public async Task Publish(object message, PublishOptions options, CancellationToken cancellationToken = default) - { - var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); - await messageSession.Publish(message, options, cancellationToken).ConfigureAwait(false); - } - - public async Task Publish(Action messageConstructor, PublishOptions options, CancellationToken cancellationToken = default) - { - var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); - await messageSession.Publish(messageConstructor, options, cancellationToken).ConfigureAwait(false); - } - - public async Task Subscribe(Type eventType, SubscribeOptions options, CancellationToken cancellationToken = default) - { - var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); - await messageSession.Subscribe(eventType, options, cancellationToken).ConfigureAwait(false); - } - - public async Task Unsubscribe(Type eventType, UnsubscribeOptions options, CancellationToken cancellationToken = default) - { - var messageSession = await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); - await messageSession.Unsubscribe(eventType, options, cancellationToken).ConfigureAwait(false); - } -} \ No newline at end of file diff --git a/src/NServiceBus.Core/Hosting/IEndpointStarter.cs b/src/NServiceBus.Core/Hosting/IEndpointStarter.cs index dccff8d01b..4e41b60672 100644 --- a/src/NServiceBus.Core/Hosting/IEndpointStarter.cs +++ b/src/NServiceBus.Core/Hosting/IEndpointStarter.cs @@ -6,7 +6,43 @@ namespace NServiceBus; using System.Threading; using System.Threading.Tasks; -interface IEndpointStarter : IAsyncDisposable +interface IEndpointStarter : IAsyncDisposable, IMessageSession { ValueTask GetOrStart(CancellationToken cancellationToken = default); + + async Task IMessageSession.Send(object message, SendOptions sendOptions, CancellationToken cancellationToken) + { + var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Send(message, sendOptions, cancellationToken).ConfigureAwait(false); + } + + async Task IMessageSession.Send(Action messageConstructor, SendOptions sendOptions, CancellationToken cancellationToken) + { + var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Send(messageConstructor, sendOptions, cancellationToken).ConfigureAwait(false); + } + + async Task IMessageSession.Publish(object message, PublishOptions publishOptions, CancellationToken cancellationToken) + { + var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Publish(message, publishOptions, cancellationToken).ConfigureAwait(false); + } + + async Task IMessageSession.Publish(Action messageConstructor, PublishOptions publishOptions, CancellationToken cancellationToken) + { + var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Publish(messageConstructor, publishOptions, cancellationToken).ConfigureAwait(false); + } + + async Task IMessageSession.Subscribe(Type eventType, SubscribeOptions subscribeOptions, CancellationToken cancellationToken) + { + var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Subscribe(eventType, subscribeOptions, cancellationToken).ConfigureAwait(false); + } + + async Task IMessageSession.Unsubscribe(Type eventType, UnsubscribeOptions unsubscribeOptions, CancellationToken cancellationToken) + { + var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); + await messageSession.Unsubscribe(eventType, unsubscribeOptions, cancellationToken).ConfigureAwait(false); + } } \ No newline at end of file diff --git a/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs index 7936d483a3..916c6f13fe 100644 --- a/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs +++ b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs @@ -43,8 +43,7 @@ public static void AddNServiceBusEndpoint( services.AddSingleton(sp => new UnkeyedEndpointStarter(startableEndpoint, sp)); services.AddSingleton(sp => new NServiceBusHostedService(sp.GetRequiredService())); - services.AddSingleton(sp => - new HostAwareMessageSession(sp.GetRequiredService())); + services.AddSingleton(sp => sp.GetRequiredService()); } else { @@ -58,7 +57,7 @@ public static void AddNServiceBusEndpoint( new NServiceBusHostedService(sp.GetRequiredKeyedService(endpointIdentifier))); services.AddKeyedSingleton(endpointIdentifier, (sp, key) => - new HostAwareMessageSession(sp.GetRequiredKeyedService(key!))); + sp.GetRequiredKeyedService(key!)); } services.AddSingleton(new EndpointRegistration(endpointName, endpointIdentifier, endpointConfiguration.AssemblyScanner().Disable, RuntimeHelpers.GetHashCode(transport))); From 74f5f356052196f71026069cd1e9a7687ec46706 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 24 Feb 2026 22:40:22 +0100 Subject: [PATCH 06/12] Enable support for slot-based logging with `Microsoft.Extensions.Logging` integration. --- .../Hosting/EndpointStarter.cs | 6 ++ .../Hosting/IEndpointStarter.cs | 9 ++ src/NServiceBus.Core/Hosting/LoggingBridge.cs | 24 +++++ .../Hosting/ServiceCollectionExtensions.cs | 2 +- .../Hosting/UnkeyedEndpointStarter.cs | 9 +- src/NServiceBus.Core/Logging/LogManager.cs | 91 ++++++++++++++++++- .../Logging/MicrosoftLoggerFactoryAdapter.cs | 51 +++++++++++ 7 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 src/NServiceBus.Core/Hosting/LoggingBridge.cs create mode 100644 src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs diff --git a/src/NServiceBus.Core/Hosting/EndpointStarter.cs b/src/NServiceBus.Core/Hosting/EndpointStarter.cs index 7bc56b0235..682c7a9f6a 100644 --- a/src/NServiceBus.Core/Hosting/EndpointStarter.cs +++ b/src/NServiceBus.Core/Hosting/EndpointStarter.cs @@ -12,6 +12,8 @@ class EndpointStarter( object serviceKey, KeyedServiceCollectionAdapter services) : IEndpointStarter { + public object LoggingSlot => serviceKey; + public async ValueTask GetOrStart(CancellationToken cancellationToken = default) { if (endpoint != null) @@ -28,6 +30,9 @@ public async ValueTask GetOrStart(CancellationToken cancellat return endpoint; } + LoggingBridge.RegisterMicrosoftFactoryIfAvailable(serviceProvider, LoggingSlot); + using var _ = LoggingBridge.BeginScope(LoggingSlot); + keyedServices = new KeyedServiceProviderAdapter(serviceProvider, serviceKey, services); endpoint = await startableEndpoint.Start(keyedServices, cancellationToken).ConfigureAwait(false); @@ -49,6 +54,7 @@ public async ValueTask DisposeAsync() if (endpoint != null) { + using var _ = LoggingBridge.BeginScope(LoggingSlot); await endpoint.Stop().ConfigureAwait(false); } diff --git a/src/NServiceBus.Core/Hosting/IEndpointStarter.cs b/src/NServiceBus.Core/Hosting/IEndpointStarter.cs index 4e41b60672..7655b306a5 100644 --- a/src/NServiceBus.Core/Hosting/IEndpointStarter.cs +++ b/src/NServiceBus.Core/Hosting/IEndpointStarter.cs @@ -5,43 +5,52 @@ namespace NServiceBus; using System; using System.Threading; using System.Threading.Tasks; +using Logging; interface IEndpointStarter : IAsyncDisposable, IMessageSession { + object LoggingSlot { get; } + ValueTask GetOrStart(CancellationToken cancellationToken = default); async Task IMessageSession.Send(object message, SendOptions sendOptions, CancellationToken cancellationToken) { + using var _ = LogManager.BeginSlotScope(LoggingSlot); var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); await messageSession.Send(message, sendOptions, cancellationToken).ConfigureAwait(false); } async Task IMessageSession.Send(Action messageConstructor, SendOptions sendOptions, CancellationToken cancellationToken) { + using var _ = LogManager.BeginSlotScope(LoggingSlot); var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); await messageSession.Send(messageConstructor, sendOptions, cancellationToken).ConfigureAwait(false); } async Task IMessageSession.Publish(object message, PublishOptions publishOptions, CancellationToken cancellationToken) { + using var _ = LogManager.BeginSlotScope(LoggingSlot); var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); await messageSession.Publish(message, publishOptions, cancellationToken).ConfigureAwait(false); } async Task IMessageSession.Publish(Action messageConstructor, PublishOptions publishOptions, CancellationToken cancellationToken) { + using var _ = LogManager.BeginSlotScope(LoggingSlot); var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); await messageSession.Publish(messageConstructor, publishOptions, cancellationToken).ConfigureAwait(false); } async Task IMessageSession.Subscribe(Type eventType, SubscribeOptions subscribeOptions, CancellationToken cancellationToken) { + using var _ = LogManager.BeginSlotScope(LoggingSlot); var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); await messageSession.Subscribe(eventType, subscribeOptions, cancellationToken).ConfigureAwait(false); } async Task IMessageSession.Unsubscribe(Type eventType, UnsubscribeOptions unsubscribeOptions, CancellationToken cancellationToken) { + using var _ = LogManager.BeginSlotScope(LoggingSlot); var messageSession = await GetOrStart(cancellationToken).ConfigureAwait(false); await messageSession.Unsubscribe(eventType, unsubscribeOptions, cancellationToken).ConfigureAwait(false); } diff --git a/src/NServiceBus.Core/Hosting/LoggingBridge.cs b/src/NServiceBus.Core/Hosting/LoggingBridge.cs new file mode 100644 index 0000000000..1ab3c650e9 --- /dev/null +++ b/src/NServiceBus.Core/Hosting/LoggingBridge.cs @@ -0,0 +1,24 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using Logging; +using Microsoft.Extensions.DependencyInjection; +using MicrosoftLoggerFactory = Microsoft.Extensions.Logging.ILoggerFactory; + +static class LoggingBridge +{ + public static IDisposable BeginScope(object slot) => LogManager.BeginSlotScope(slot); + + public static void RegisterMicrosoftFactoryIfAvailable(IServiceProvider serviceProvider, object slot) + { + var microsoftLoggerFactory = serviceProvider.GetService(); + if (microsoftLoggerFactory is null) + { + return; + } + + LogManager.RegisterSlotFactory(slot, new MicrosoftLoggerFactoryAdapter(microsoftLoggerFactory)); + } +} diff --git a/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs index 916c6f13fe..460b3366d4 100644 --- a/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs +++ b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs @@ -40,7 +40,7 @@ public static void AddNServiceBusEndpoint( { var startableEndpoint = EndpointWithExternallyManagedContainer.Create(endpointConfiguration, services); - services.AddSingleton(sp => new UnkeyedEndpointStarter(startableEndpoint, sp)); + services.AddSingleton(sp => new UnkeyedEndpointStarter(startableEndpoint, sp, endpointName)); services.AddSingleton(sp => new NServiceBusHostedService(sp.GetRequiredService())); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs b/src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs index 82d1a9072b..438a071195 100644 --- a/src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs +++ b/src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs @@ -8,8 +8,11 @@ namespace NServiceBus; sealed class UnkeyedEndpointStarter( IStartableEndpointWithExternallyManagedContainer startableEndpoint, - IServiceProvider serviceProvider) : IEndpointStarter + IServiceProvider serviceProvider, + object loggingSlot) : IEndpointStarter { + public object LoggingSlot => loggingSlot; + public async ValueTask GetOrStart(CancellationToken cancellationToken = default) { if (endpoint != null) @@ -26,6 +29,9 @@ public async ValueTask GetOrStart(CancellationToken cancellat return endpoint; } + LoggingBridge.RegisterMicrosoftFactoryIfAvailable(serviceProvider, LoggingSlot); + using var _ = LoggingBridge.BeginScope(LoggingSlot); + endpoint = await startableEndpoint.Start(serviceProvider, cancellationToken).ConfigureAwait(false); return endpoint; @@ -43,6 +49,7 @@ public async ValueTask DisposeAsync() return; } + using var _ = LoggingBridge.BeginScope(LoggingSlot); await endpoint.Stop().ConfigureAwait(false); startSemaphore.Dispose(); } diff --git a/src/NServiceBus.Core/Logging/LogManager.cs b/src/NServiceBus.Core/Logging/LogManager.cs index a95bf6486d..83e991de9d 100644 --- a/src/NServiceBus.Core/Logging/LogManager.cs +++ b/src/NServiceBus.Core/Logging/LogManager.cs @@ -3,6 +3,8 @@ namespace NServiceBus.Logging; using System; +using System.Collections.Concurrent; +using System.Threading; /// /// Responsible for the creation of instances and used as an extension point to redirect log events to @@ -20,7 +22,7 @@ public static class LogManager { var loggingDefinition = new T(); - loggerFactory = new Lazy(loggingDefinition.GetLoggingFactory); + defaultLoggerFactory = new Lazy(loggingDefinition.GetLoggingFactory); return loggingDefinition; } @@ -35,7 +37,7 @@ public static void UseFactory(ILoggerFactory loggerFactory) { ArgumentNullException.ThrowIfNull(loggerFactory); - LogManager.loggerFactory = new Lazy(() => loggerFactory); + defaultLoggerFactory = new Lazy(() => loggerFactory); } /// @@ -49,7 +51,7 @@ public static void UseFactory(ILoggerFactory loggerFactory) public static ILog GetLogger(Type type) { ArgumentNullException.ThrowIfNull(type); - return loggerFactory.Value.GetLogger(type); + return new SlotAwareLogger(type.FullName!); } /// @@ -58,8 +60,87 @@ public static ILog GetLogger(Type type) public static ILog GetLogger(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - return loggerFactory.Value.GetLogger(name); + return new SlotAwareLogger(name); } - static Lazy loggerFactory = new(new DefaultFactory().GetLoggingFactory); + internal static void RegisterSlotFactory(object slot, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(slot); + ArgumentNullException.ThrowIfNull(loggerFactory); + + slotLoggerFactories[new SlotKey(slot)] = loggerFactory; + } + + internal static IDisposable BeginSlotScope(object slot) + { + ArgumentNullException.ThrowIfNull(slot); + + return new SlotScope(slot); + } + + static ILoggerFactory GetLoggerFactoryForCurrentSlot() + { + var slot = currentSlot.Value; + if (slot is not null && slotLoggerFactories.TryGetValue(new SlotKey(slot), out var loggerFactory)) + { + return loggerFactory; + } + + return defaultLoggerFactory.Value; + } + + sealed class SlotAwareLogger(string name) : ILog + { + public bool IsDebugEnabled => GetLogger().IsDebugEnabled; + public bool IsInfoEnabled => GetLogger().IsInfoEnabled; + public bool IsWarnEnabled => GetLogger().IsWarnEnabled; + public bool IsErrorEnabled => GetLogger().IsErrorEnabled; + public bool IsFatalEnabled => GetLogger().IsFatalEnabled; + + public void Debug(string? message) => GetLogger().Debug(message); + public void Debug(string? message, Exception? exception) => GetLogger().Debug(message, exception); + public void DebugFormat(string format, params object?[] args) => GetLogger().DebugFormat(format, args); + public void Info(string? message) => GetLogger().Info(message); + public void Info(string? message, Exception? exception) => GetLogger().Info(message, exception); + public void InfoFormat(string format, params object?[] args) => GetLogger().InfoFormat(format, args); + public void Warn(string? message) => GetLogger().Warn(message); + public void Warn(string? message, Exception? exception) => GetLogger().Warn(message, exception); + public void WarnFormat(string format, params object?[] args) => GetLogger().WarnFormat(format, args); + public void Error(string? message) => GetLogger().Error(message); + public void Error(string? message, Exception? exception) => GetLogger().Error(message, exception); + public void ErrorFormat(string format, params object?[] args) => GetLogger().ErrorFormat(format, args); + public void Fatal(string? message) => GetLogger().Fatal(message); + public void Fatal(string? message, Exception? exception) => GetLogger().Fatal(message, exception); + public void FatalFormat(string format, params object?[] args) => GetLogger().FatalFormat(format, args); + + ILog GetLogger() => GetLoggerFactoryForCurrentSlot().GetLogger(name); + } + + sealed class SlotScope : IDisposable + { + public SlotScope(object slot) + { + previousSlot = currentSlot.Value; + currentSlot.Value = slot; + } + + public void Dispose() => currentSlot.Value = previousSlot; + + readonly object? previousSlot; + } + + readonly struct SlotKey(object value) : IEquatable + { + public bool Equals(SlotKey other) => Equals(Value, other.Value); + + public override bool Equals(object? obj) => obj is SlotKey other && Equals(other); + + public override int GetHashCode() => Value.GetHashCode(); + + readonly object Value = value; + } + + static Lazy defaultLoggerFactory = new(new DefaultFactory().GetLoggingFactory); + static readonly AsyncLocal currentSlot = new(); + static readonly ConcurrentDictionary slotLoggerFactories = new(); } \ No newline at end of file diff --git a/src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs b/src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs new file mode 100644 index 0000000000..8c46c3f761 --- /dev/null +++ b/src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs @@ -0,0 +1,51 @@ +#nullable enable + +namespace NServiceBus.Logging; + +using System; +using MicrosoftLoggerFactory = Microsoft.Extensions.Logging.ILoggerFactory; +using MicrosoftLogger = Microsoft.Extensions.Logging.ILogger; +using MicrosoftLogLevel = Microsoft.Extensions.Logging.LogLevel; + +sealed class MicrosoftLoggerFactoryAdapter(MicrosoftLoggerFactory loggerFactory) : ILoggerFactory +{ + public ILog GetLogger(Type type) + { + ArgumentNullException.ThrowIfNull(type); + return new MicrosoftLoggerAdapter(loggerFactory.CreateLogger(type.FullName!)); + } + + public ILog GetLogger(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return new MicrosoftLoggerAdapter(loggerFactory.CreateLogger(name)); + } + + sealed class MicrosoftLoggerAdapter(MicrosoftLogger logger) : ILog + { + public bool IsDebugEnabled => logger.IsEnabled(MicrosoftLogLevel.Debug); + public bool IsInfoEnabled => logger.IsEnabled(MicrosoftLogLevel.Information); + public bool IsWarnEnabled => logger.IsEnabled(MicrosoftLogLevel.Warning); + public bool IsErrorEnabled => logger.IsEnabled(MicrosoftLogLevel.Error); + public bool IsFatalEnabled => logger.IsEnabled(MicrosoftLogLevel.Critical); + + public void Debug(string? message) => Log(MicrosoftLogLevel.Debug, message); + public void Debug(string? message, Exception? exception) => Log(MicrosoftLogLevel.Debug, message, exception); + public void DebugFormat(string format, params object?[] args) => Log(MicrosoftLogLevel.Debug, string.Format(format, args)); + public void Info(string? message) => Log(MicrosoftLogLevel.Information, message); + public void Info(string? message, Exception? exception) => Log(MicrosoftLogLevel.Information, message, exception); + public void InfoFormat(string format, params object?[] args) => Log(MicrosoftLogLevel.Information, string.Format(format, args)); + public void Warn(string? message) => Log(MicrosoftLogLevel.Warning, message); + public void Warn(string? message, Exception? exception) => Log(MicrosoftLogLevel.Warning, message, exception); + public void WarnFormat(string format, params object?[] args) => Log(MicrosoftLogLevel.Warning, string.Format(format, args)); + public void Error(string? message) => Log(MicrosoftLogLevel.Error, message); + public void Error(string? message, Exception? exception) => Log(MicrosoftLogLevel.Error, message, exception); + public void ErrorFormat(string format, params object?[] args) => Log(MicrosoftLogLevel.Error, string.Format(format, args)); + public void Fatal(string? message) => Log(MicrosoftLogLevel.Critical, message); + public void Fatal(string? message, Exception? exception) => Log(MicrosoftLogLevel.Critical, message, exception); + public void FatalFormat(string format, params object?[] args) => Log(MicrosoftLogLevel.Critical, string.Format(format, args)); + + void Log(MicrosoftLogLevel level, string? message, Exception? exception = null) => + logger.Log(level, eventId: default, state: message, exception, static (s, _) => s ?? string.Empty); + } +} \ No newline at end of file From 1dac798a052fa67eb9353abb73044814204bdda0 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 24 Feb 2026 22:42:07 +0100 Subject: [PATCH 07/12] Simplify `DisposeAsync` implementation in `NServiceBusHostedService`. --- src/NServiceBus.Core/Hosting/NServiceBusHostedService.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/NServiceBus.Core/Hosting/NServiceBusHostedService.cs b/src/NServiceBus.Core/Hosting/NServiceBusHostedService.cs index 7ab34b19ac..d1f3ee0b9b 100644 --- a/src/NServiceBus.Core/Hosting/NServiceBusHostedService.cs +++ b/src/NServiceBus.Core/Hosting/NServiceBusHostedService.cs @@ -17,10 +17,7 @@ sealed class NServiceBusHostedService(IEndpointStarter endpointStarter) : IHoste public Task StoppedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - public async ValueTask DisposeAsync() - { - await endpointStarter.DisposeAsync().ConfigureAwait(false); - } + public ValueTask DisposeAsync() => endpointStarter.DisposeAsync(); public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; From 1bce4992795afe5cabbeb2b14b99bf689fd90a45 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 24 Feb 2026 23:10:11 +0100 Subject: [PATCH 08/12] Enhance `LogManager` to support scoped slot-based logging and integrate endpoint contextual logging. --- src/NServiceBus.Core/Logging/LogManager.cs | 313 ++++++++++++++++-- .../Logging/MicrosoftLoggerFactoryAdapter.cs | 47 ++- 2 files changed, 323 insertions(+), 37 deletions(-) diff --git a/src/NServiceBus.Core/Logging/LogManager.cs b/src/NServiceBus.Core/Logging/LogManager.cs index 83e991de9d..a6a7c5e004 100644 --- a/src/NServiceBus.Core/Logging/LogManager.cs +++ b/src/NServiceBus.Core/Logging/LogManager.cs @@ -4,6 +4,7 @@ namespace NServiceBus.Logging; using System; using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Threading; /// @@ -51,7 +52,7 @@ public static void UseFactory(ILoggerFactory loggerFactory) public static ILog GetLogger(Type type) { ArgumentNullException.ThrowIfNull(type); - return new SlotAwareLogger(type.FullName!); + return GetLogger(type.FullName!); } /// @@ -60,7 +61,7 @@ public static ILog GetLogger(Type type) public static ILog GetLogger(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - return new SlotAwareLogger(name); + return loggers.GetOrAdd(name, static loggerName => new SlotAwareLogger(loggerName)); } internal static void RegisterSlotFactory(object slot, ILoggerFactory loggerFactory) @@ -68,57 +69,288 @@ internal static void RegisterSlotFactory(object slot, ILoggerFactory loggerFacto ArgumentNullException.ThrowIfNull(slot); ArgumentNullException.ThrowIfNull(loggerFactory); - slotLoggerFactories[new SlotKey(slot)] = loggerFactory; + var slotKey = new SlotKey(slot); + slotLoggerFactories[slotKey] = loggerFactory; + var slotContext = slotContexts.GetOrAdd(slotKey, static key => new SlotContext(key.Value)); + + using var _ = new SlotScope(slotContext); + foreach (var logger in loggers.Values) + { + logger.Flush(slotKey, loggerFactory); + } } internal static IDisposable BeginSlotScope(object slot) { ArgumentNullException.ThrowIfNull(slot); - return new SlotScope(slot); + var slotKey = new SlotKey(slot); + var slotContext = slotContexts.GetOrAdd(slotKey, static key => new SlotContext(key.Value)); + return new SlotScope(slotContext); + } + + internal static bool TryGetCurrentEndpointIdentifier(out object endpointIdentifier) + { + if (currentSlot.Value is null) + { + endpointIdentifier = null!; + return false; + } + + endpointIdentifier = currentSlot.Value.Identifier; + return true; } - static ILoggerFactory GetLoggerFactoryForCurrentSlot() + static bool TryGetSlotLoggerFactory(out SlotContext slotContext, out ILoggerFactory loggerFactory) { - var slot = currentSlot.Value; - if (slot is not null && slotLoggerFactories.TryGetValue(new SlotKey(slot), out var loggerFactory)) + var current = currentSlot.Value; + if (current is not null && slotLoggerFactories.TryGetValue(current.Key, out var foundFactory)) { - return loggerFactory; + loggerFactory = foundFactory; + slotContext = current; + return true; } - return defaultLoggerFactory.Value; + slotContext = null!; + loggerFactory = null!; + return false; } sealed class SlotAwareLogger(string name) : ILog { - public bool IsDebugEnabled => GetLogger().IsDebugEnabled; - public bool IsInfoEnabled => GetLogger().IsInfoEnabled; - public bool IsWarnEnabled => GetLogger().IsWarnEnabled; - public bool IsErrorEnabled => GetLogger().IsErrorEnabled; - public bool IsFatalEnabled => GetLogger().IsFatalEnabled; - - public void Debug(string? message) => GetLogger().Debug(message); - public void Debug(string? message, Exception? exception) => GetLogger().Debug(message, exception); - public void DebugFormat(string format, params object?[] args) => GetLogger().DebugFormat(format, args); - public void Info(string? message) => GetLogger().Info(message); - public void Info(string? message, Exception? exception) => GetLogger().Info(message, exception); - public void InfoFormat(string format, params object?[] args) => GetLogger().InfoFormat(format, args); - public void Warn(string? message) => GetLogger().Warn(message); - public void Warn(string? message, Exception? exception) => GetLogger().Warn(message, exception); - public void WarnFormat(string format, params object?[] args) => GetLogger().WarnFormat(format, args); - public void Error(string? message) => GetLogger().Error(message); - public void Error(string? message, Exception? exception) => GetLogger().Error(message, exception); - public void ErrorFormat(string format, params object?[] args) => GetLogger().ErrorFormat(format, args); - public void Fatal(string? message) => GetLogger().Fatal(message); - public void Fatal(string? message, Exception? exception) => GetLogger().Fatal(message, exception); - public void FatalFormat(string format, params object?[] args) => GetLogger().FatalFormat(format, args); - - ILog GetLogger() => GetLoggerFactoryForCurrentSlot().GetLogger(name); + public bool IsDebugEnabled => IsEnabled(static l => l.IsDebugEnabled); + public bool IsInfoEnabled => IsEnabled(static l => l.IsInfoEnabled); + public bool IsWarnEnabled => IsEnabled(static l => l.IsWarnEnabled); + public bool IsErrorEnabled => IsEnabled(static l => l.IsErrorEnabled); + public bool IsFatalEnabled => IsEnabled(static l => l.IsFatalEnabled); + + public void Debug(string? message) => Write(LogLevel.Debug, message, + static (logger, payload) => logger.Debug(payload)); + + public void Debug(string? message, Exception? exception) => Write(LogLevel.Debug, message, exception, + static (logger, payload, ex) => logger.Debug(payload, ex)); + + public void DebugFormat(string format, params object?[] args) => Write(LogLevel.Debug, format, args, + static (logger, payload, payloadArgs) => logger.DebugFormat(payload, payloadArgs)); + + public void Info(string? message) => Write(LogLevel.Info, message, + static (logger, payload) => logger.Info(payload)); + + public void Info(string? message, Exception? exception) => Write(LogLevel.Info, message, exception, + static (logger, payload, ex) => logger.Info(payload, ex)); + + public void InfoFormat(string format, params object?[] args) => Write(LogLevel.Info, format, args, + static (logger, payload, payloadArgs) => logger.InfoFormat(payload, payloadArgs)); + + public void Warn(string? message) => Write(LogLevel.Warn, message, + static (logger, payload) => logger.Warn(payload)); + + public void Warn(string? message, Exception? exception) => Write(LogLevel.Warn, message, exception, + static (logger, payload, ex) => logger.Warn(payload, ex)); + + public void WarnFormat(string format, params object?[] args) => Write(LogLevel.Warn, format, args, + static (logger, payload, payloadArgs) => logger.WarnFormat(payload, payloadArgs)); + + public void Error(string? message) => Write(LogLevel.Error, message, + static (logger, payload) => logger.Error(payload)); + + public void Error(string? message, Exception? exception) => Write(LogLevel.Error, message, exception, + static (logger, payload, ex) => logger.Error(payload, ex)); + + public void ErrorFormat(string format, params object?[] args) => Write(LogLevel.Error, format, args, + static (logger, payload, payloadArgs) => logger.ErrorFormat(payload, payloadArgs)); + + public void Fatal(string? message) => Write(LogLevel.Fatal, message, + static (logger, payload) => logger.Fatal(payload)); + + public void Fatal(string? message, Exception? exception) => Write(LogLevel.Fatal, message, exception, + static (logger, payload, ex) => logger.Fatal(payload, ex)); + + public void FatalFormat(string format, params object?[] args) => Write(LogLevel.Fatal, format, args, + static (logger, payload, payloadArgs) => logger.FatalFormat(payload, payloadArgs)); + + public void Flush(SlotKey slotKey, ILoggerFactory loggerFactory) + { + if (!deferredLogsBySlot.TryGetValue(slotKey, out var deferredLogs)) + { + return; + } + + var logger = slotLoggers.GetOrAdd(slotKey, _ => loggerFactory.GetLogger(name)); + deferredLogs.FlushTo(logger); + } + + bool IsEnabled(Func isEnabled) + { + if (TryGetLogger(out var logger)) + { + return isEnabled(logger); + } + + return TryGetCurrentSlotContext(out _) || isEnabled(defaultLoggerFactory.Value.GetLogger(name)); + } + + void Write(LogLevel level, string? message, Action writeAction) + { + if (TryGetLogger(out var logger)) + { + writeAction(logger, message); + return; + } + + if (TryGetCurrentSlotContext(out var slotContext)) + { + var deferredLogs = deferredLogsBySlot.GetOrAdd(slotContext.Key, _ => new DeferredLogs()); + deferredLogs.DeferredMessageLogs.Enqueue((level, message)); + return; + } + + writeAction(defaultLoggerFactory.Value.GetLogger(name), message); + } + + void Write(LogLevel level, string? message, Exception? exception, Action writeAction) + { + if (TryGetLogger(out var logger)) + { + writeAction(logger, message, exception); + return; + } + + if (TryGetCurrentSlotContext(out var slotContext)) + { + var deferredLogs = deferredLogsBySlot.GetOrAdd(slotContext.Key, _ => new DeferredLogs()); + deferredLogs.DeferredExceptionLogs.Enqueue((level, message, exception)); + return; + } + + writeAction(defaultLoggerFactory.Value.GetLogger(name), message, exception); + } + + void Write(LogLevel level, string format, object?[] args, Action writeAction) + { + if (TryGetLogger(out var logger)) + { + writeAction(logger, format, args); + return; + } + + if (TryGetCurrentSlotContext(out var slotContext)) + { + var deferredLogs = deferredLogsBySlot.GetOrAdd(slotContext.Key, _ => new DeferredLogs()); + deferredLogs.DeferredFormatLogs.Enqueue((level, format, args)); + return; + } + + writeAction(defaultLoggerFactory.Value.GetLogger(name), format, args); + } + + bool TryGetLogger(out ILog logger) + { + if (TryGetSlotLoggerFactory(out var slotContext, out var loggerFactory)) + { + logger = slotLoggers.GetOrAdd(slotContext.Key, _ => loggerFactory.GetLogger(name)); + return true; + } + + logger = null!; + return false; + } + + static bool TryGetCurrentSlotContext([NotNullWhen(true)] out SlotContext? slotContext) + { + slotContext = currentSlot.Value; + return slotContext is not null; + } + + readonly ConcurrentDictionary deferredLogsBySlot = new(); + readonly ConcurrentDictionary slotLoggers = new(); + } + + sealed class DeferredLogs + { + public readonly ConcurrentQueue<(LogLevel level, string? message)> DeferredMessageLogs = new(); + public readonly ConcurrentQueue<(LogLevel level, string? message, Exception? exception)> DeferredExceptionLogs = new(); + public readonly ConcurrentQueue<(LogLevel level, string format, object?[] args)> DeferredFormatLogs = new(); + + public void FlushTo(ILog logger) + { + while (DeferredMessageLogs.TryDequeue(out var messageLog)) + { + switch (messageLog.level) + { + case LogLevel.Debug: + logger.Debug(messageLog.message); + break; + case LogLevel.Info: + logger.Info(messageLog.message); + break; + case LogLevel.Warn: + logger.Warn(messageLog.message); + break; + case LogLevel.Error: + logger.Error(messageLog.message); + break; + case LogLevel.Fatal: + logger.Fatal(messageLog.message); + break; + default: + throw new InvalidOperationException($"Unsupported log level '{messageLog.level}'."); + } + } + + while (DeferredExceptionLogs.TryDequeue(out var exceptionLog)) + { + switch (exceptionLog.level) + { + case LogLevel.Debug: + logger.Debug(exceptionLog.message, exceptionLog.exception); + break; + case LogLevel.Info: + logger.Info(exceptionLog.message, exceptionLog.exception); + break; + case LogLevel.Warn: + logger.Warn(exceptionLog.message, exceptionLog.exception); + break; + case LogLevel.Error: + logger.Error(exceptionLog.message, exceptionLog.exception); + break; + case LogLevel.Fatal: + logger.Fatal(exceptionLog.message, exceptionLog.exception); + break; + default: + throw new InvalidOperationException($"Unsupported log level '{exceptionLog.level}'."); + } + } + + while (DeferredFormatLogs.TryDequeue(out var formatLog)) + { + switch (formatLog.level) + { + case LogLevel.Debug: + logger.DebugFormat(formatLog.format, formatLog.args); + break; + case LogLevel.Info: + logger.InfoFormat(formatLog.format, formatLog.args); + break; + case LogLevel.Warn: + logger.WarnFormat(formatLog.format, formatLog.args); + break; + case LogLevel.Error: + logger.ErrorFormat(formatLog.format, formatLog.args); + break; + case LogLevel.Fatal: + logger.FatalFormat(formatLog.format, formatLog.args); + break; + default: + throw new InvalidOperationException($"Unsupported log level '{formatLog.level}'."); + } + } + } } sealed class SlotScope : IDisposable { - public SlotScope(object slot) + public SlotScope(SlotContext slot) { previousSlot = currentSlot.Value; currentSlot.Value = slot; @@ -126,21 +358,30 @@ public SlotScope(object slot) public void Dispose() => currentSlot.Value = previousSlot; - readonly object? previousSlot; + readonly SlotContext? previousSlot; + } + + sealed class SlotContext(object identifier) + { + public object Identifier { get; } = identifier; + public SlotKey Key { get; } = new(identifier); } readonly struct SlotKey(object value) : IEquatable { + public object Value { get; } = value; + public bool Equals(SlotKey other) => Equals(Value, other.Value); public override bool Equals(object? obj) => obj is SlotKey other && Equals(other); public override int GetHashCode() => Value.GetHashCode(); - readonly object Value = value; } static Lazy defaultLoggerFactory = new(new DefaultFactory().GetLoggingFactory); - static readonly AsyncLocal currentSlot = new(); + static readonly AsyncLocal currentSlot = new(); + static readonly ConcurrentDictionary loggers = new(StringComparer.Ordinal); + static readonly ConcurrentDictionary slotContexts = new(); static readonly ConcurrentDictionary slotLoggerFactories = new(); } \ No newline at end of file diff --git a/src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs b/src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs index 8c46c3f761..e7f12fcc66 100644 --- a/src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs +++ b/src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs @@ -3,6 +3,8 @@ namespace NServiceBus.Logging; using System; +using System.Collections; +using System.Collections.Generic; using MicrosoftLoggerFactory = Microsoft.Extensions.Logging.ILoggerFactory; using MicrosoftLogger = Microsoft.Extensions.Logging.ILogger; using MicrosoftLogLevel = Microsoft.Extensions.Logging.LogLevel; @@ -45,7 +47,50 @@ sealed class MicrosoftLoggerAdapter(MicrosoftLogger logger) : ILog public void Fatal(string? message, Exception? exception) => Log(MicrosoftLogLevel.Critical, message, exception); public void FatalFormat(string format, params object?[] args) => Log(MicrosoftLogLevel.Critical, string.Format(format, args)); - void Log(MicrosoftLogLevel level, string? message, Exception? exception = null) => + void Log(MicrosoftLogLevel level, string? message, Exception? exception = null) + { + using var _ = BeginScope(); logger.Log(level, eventId: default, state: message, exception, static (s, _) => s ?? string.Empty); + } + + IDisposable BeginScope() + { + if (!LogManager.TryGetCurrentEndpointIdentifier(out var endpointIdentifier)) + { + return NullScope.Instance; + } + + return logger.BeginScope(new EndpointScope(endpointIdentifier)) ?? NullScope.Instance; + } + + sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } + + sealed class EndpointScope(object endpointIdentifier) : IReadOnlyList> + { + public KeyValuePair this[int index] => + index switch + { + 0 => new KeyValuePair("Endpoint", endpointIdentifier), + _ => throw new ArgumentOutOfRangeException(nameof(index)) + }; + + public int Count => 1; + + public IEnumerator> GetEnumerator() + { + yield return this[0]; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public override string ToString() => $"Endpoint = {endpointIdentifier}"; + } } } \ No newline at end of file From 1a6e44dea60f9b16b9b202490abb9f5a82d28436 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Wed, 25 Feb 2026 12:02:33 +0100 Subject: [PATCH 09/12] Move logging scope management into the internals --- .../Logging/EndpointLoggingScopeTests.cs | 113 ++++++++++++++ .../Pipeline/MainPipelineExecutorTests.cs | 2 + .../LogWrappedMessageReceiverTests.cs | 147 ++++++++++++++++++ .../Unicast/RunningEndpointInstanceTest.cs | 4 +- .../Hosting/EndpointStarter.cs | 5 +- .../Hosting/HostingComponent.Configuration.cs | 7 +- .../Hosting/HostingComponent.Settings.cs | 44 ++++++ src/NServiceBus.Core/Hosting/LoggingBridge.cs | 4 +- .../Hosting/ServiceCollectionExtensions.cs | 8 +- .../Hosting/UnkeyedEndpointStarter.cs | 2 - .../Logging/EndpointLogScopeState.cs | 41 +++++ src/NServiceBus.Core/Logging/LogManager.cs | 21 ++- .../Logging/MicrosoftLoggerFactoryAdapter.cs | 26 +--- .../Pipeline/MainPipelineExecutor.cs | 4 + .../Pipeline/SatellitePipelineExecutor.cs | 5 +- .../Receiving/LogWrappedMessageReceiver.cs | 45 ++++++ .../Receiving/ReceiveComponent.cs | 32 ++-- src/NServiceBus.Core/StartableEndpoint.cs | 13 +- .../Unicast/RunningEndpointInstance.cs | 5 +- 19 files changed, 472 insertions(+), 56 deletions(-) create mode 100644 src/NServiceBus.Core.Tests/Logging/EndpointLoggingScopeTests.cs create mode 100644 src/NServiceBus.Core.Tests/Receiving/LogWrappedMessageReceiverTests.cs create mode 100644 src/NServiceBus.Core/Logging/EndpointLogScopeState.cs create mode 100644 src/NServiceBus.Core/Receiving/LogWrappedMessageReceiver.cs diff --git a/src/NServiceBus.Core.Tests/Logging/EndpointLoggingScopeTests.cs b/src/NServiceBus.Core.Tests/Logging/EndpointLoggingScopeTests.cs new file mode 100644 index 0000000000..e7162c6b3d --- /dev/null +++ b/src/NServiceBus.Core.Tests/Logging/EndpointLoggingScopeTests.cs @@ -0,0 +1,113 @@ +#nullable enable + +namespace NServiceBus.Core.Tests.Logging; + +using System; +using System.Collections.Generic; +using System.Linq; +using NServiceBus.Logging; +using NUnit.Framework; + +[TestFixture] +public class EndpointLoggingScopeTests +{ + [Test] + public void Should_include_endpoint_name_and_identifier_for_multi_hosted_endpoints() + { + var loggerFactory = new CollectingMicrosoftLoggerFactory(); + var slot = new EndpointLogSlot("Sales", "blue"); + LogManager.RegisterSlotFactory(slot, new MicrosoftLoggerFactoryAdapter(loggerFactory)); + + var logger = LogManager.GetLogger($"{nameof(EndpointLoggingScopeTests)}-{Guid.NewGuid():N}"); + + using (LogManager.BeginSlotScope(slot)) + { + logger.Info("message"); + } + + var scope = loggerFactory.Logger.CapturedScopes.Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(scope.Count, Is.EqualTo(2)); + Assert.That(scope[0].Key, Is.EqualTo("Endpoint")); + Assert.That(scope[0].Value, Is.EqualTo("Sales")); + Assert.That(scope[1].Key, Is.EqualTo("EndpointIdentifier")); + Assert.That(scope[1].Value, Is.EqualTo("blue")); + } + } + + [Test] + public void Should_include_only_endpoint_name_when_identifier_is_not_provided() + { + var loggerFactory = new CollectingMicrosoftLoggerFactory(); + var slot = new EndpointLogSlot("Billing", endpointIdentifier: null); + LogManager.RegisterSlotFactory(slot, new MicrosoftLoggerFactoryAdapter(loggerFactory)); + + var logger = LogManager.GetLogger($"{nameof(EndpointLoggingScopeTests)}-{Guid.NewGuid():N}"); + + using (LogManager.BeginSlotScope(slot)) + { + logger.Info("message"); + } + + var scope = loggerFactory.Logger.CapturedScopes.Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(scope.Count, Is.EqualTo(1)); + Assert.That(scope[0].Key, Is.EqualTo("Endpoint")); + Assert.That(scope[0].Value, Is.EqualTo("Billing")); + } + } + + sealed class CollectingMicrosoftLoggerFactory : Microsoft.Extensions.Logging.ILoggerFactory + { + public CollectingMicrosoftLogger Logger { get; } = new(); + + public void AddProvider(Microsoft.Extensions.Logging.ILoggerProvider provider) + { + } + + public Microsoft.Extensions.Logging.ILogger CreateLogger(string categoryName) => Logger; + + public void Dispose() + { + } + } + + sealed class CollectingMicrosoftLogger : Microsoft.Extensions.Logging.ILogger + { + public List>> CapturedScopes { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull + { + if (state is IReadOnlyList> scope) + { + CapturedScopes.Add(scope); + } + + return NullScope.Instance; + } + + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + + public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, + Microsoft.Extensions.Logging.EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + } + + sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } + } +} \ No newline at end of file diff --git a/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs b/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs index f6223a25ea..4b6cdc8a15 100644 --- a/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs +++ b/src/NServiceBus.Core.Tests/Pipeline/MainPipelineExecutorTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Extensibility; using Microsoft.Extensions.DependencyInjection; +using NServiceBus.Logging; using NServiceBus.Pipeline; using NUnit.Framework; using OpenTelemetry; @@ -130,6 +131,7 @@ static MainPipelineExecutor CreateMainPipelineExecutor(ServiceProvider servicePr new TestableMessageOperations(), new Notification(), receivePipeline, + new EndpointLogSlot("MainPipelineExecutorTests", endpointIdentifier: null), new ActivityFactory(), incomingPipelineMetrics, new EnvelopeUnwrapper([], incomingPipelineMetrics)); diff --git a/src/NServiceBus.Core.Tests/Receiving/LogWrappedMessageReceiverTests.cs b/src/NServiceBus.Core.Tests/Receiving/LogWrappedMessageReceiverTests.cs new file mode 100644 index 0000000000..7141f35594 --- /dev/null +++ b/src/NServiceBus.Core.Tests/Receiving/LogWrappedMessageReceiverTests.cs @@ -0,0 +1,147 @@ +#nullable enable + +namespace NServiceBus.Core.Tests.Receiving; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Extensibility; +using NServiceBus.Logging; +using NServiceBus.Transport; +using NUnit.Framework; + +[TestFixture] +public class LogWrappedMessageReceiverTests +{ + [Test] + public async Task Should_scope_on_message_callback() + { + var slot = new EndpointLogSlot("Sales", endpointIdentifier: "blue"); + var receiver = new TestReceiver(); + var wrapped = new LogWrappedMessageReceiver(receiver, slot); + + EndpointLogScopeState? scope = null; + + await wrapped.Initialize(new PushRuntimeSettings(1), (messageContext, ct) => + { + _ = messageContext; + _ = ct; + Assert.That(LogManager.TryGetCurrentEndpointScopeState(out var currentScope), Is.True); + scope = currentScope; + return Task.CompletedTask; + }, (errorContext, ct) => + { + _ = errorContext; + _ = ct; + return Task.FromResult(ErrorHandleResult.Handled); + }); + + await receiver.InvokeMessage(CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(scope, Is.Not.Null); + Assert.That(scope![0].Key, Is.EqualTo("Endpoint")); + Assert.That(scope[0].Value, Is.EqualTo("Sales")); + Assert.That(scope[1].Key, Is.EqualTo("EndpointIdentifier")); + Assert.That(scope[1].Value, Is.EqualTo("blue")); + } + } + + [Test] + public async Task Should_scope_on_error_callback() + { + var slot = new EndpointLogSlot("Billing", endpointIdentifier: null); + var receiver = new TestReceiver(); + var wrapped = new LogWrappedMessageReceiver(receiver, slot); + + EndpointLogScopeState? scope = null; + + await wrapped.Initialize(new PushRuntimeSettings(1), (messageContext, ct) => + { + _ = messageContext; + _ = ct; + return Task.CompletedTask; + }, (errorContext, ct) => + { + _ = errorContext; + _ = ct; + Assert.That(LogManager.TryGetCurrentEndpointScopeState(out var currentScope), Is.True); + scope = currentScope; + return Task.FromResult(ErrorHandleResult.Handled); + }); + + var result = await receiver.InvokeError(CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.EqualTo(ErrorHandleResult.Handled)); + Assert.That(scope, Is.Not.Null); + Assert.That(scope!.Count, Is.EqualTo(1)); + Assert.That(scope[0].Key, Is.EqualTo("Endpoint")); + Assert.That(scope[0].Value, Is.EqualTo("Billing")); + } + } + + sealed class TestReceiver : IMessageReceiver + { + public ISubscriptionManager Subscriptions { get; } = null!; + public string Id => "receiver"; + public string ReceiveAddress => "queue"; + + public Task ChangeConcurrency(PushRuntimeSettings limitations, CancellationToken cancellationToken = default) + { + _ = limitations; + _ = cancellationToken; + return Task.CompletedTask; + } + + public Task StartReceive(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + return Task.CompletedTask; + } + + public Task StopReceive(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + return Task.CompletedTask; + } + + public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnError onError, CancellationToken cancellationToken = default) + { + _ = limitations; + _ = cancellationToken; + onMessageCallback = onMessage; + onErrorCallback = onError; + return Task.CompletedTask; + } + + public Task InvokeMessage(CancellationToken cancellationToken = default) => onMessageCallback(CreateMessageContext(), cancellationToken); + + public Task InvokeError(CancellationToken cancellationToken = default) => onErrorCallback(CreateErrorContext(), cancellationToken); + + OnMessage onMessageCallback = null!; + OnError onErrorCallback = null!; + + static MessageContext CreateMessageContext() => + new( + Guid.NewGuid().ToString(), + [], + Array.Empty(), + new TransportTransaction(), + "receiver", + new ContextBag()); + + static ErrorContext CreateErrorContext() => + new( + new Exception("boom"), + [], + Guid.NewGuid().ToString(), + Array.Empty(), + new TransportTransaction(), + immediateProcessingFailures: 1, + receiveAddress: "queue", + new ContextBag()); + } +} \ No newline at end of file diff --git a/src/NServiceBus.Core.Tests/Unicast/RunningEndpointInstanceTest.cs b/src/NServiceBus.Core.Tests/Unicast/RunningEndpointInstanceTest.cs index 90af367bc3..afb04f1663 100644 --- a/src/NServiceBus.Core.Tests/Unicast/RunningEndpointInstanceTest.cs +++ b/src/NServiceBus.Core.Tests/Unicast/RunningEndpointInstanceTest.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Features; +using NServiceBus.Logging; using NUnit.Framework; using Settings; using Testing; @@ -22,7 +23,8 @@ static RunningEndpointInstance Create() new TestableMessageSession(), null, new CancellationTokenSource(), - null); + null, + new EndpointLogSlot("RunningEndpointInstanceTest", endpointIdentifier: null)); return testInstance; } diff --git a/src/NServiceBus.Core/Hosting/EndpointStarter.cs b/src/NServiceBus.Core/Hosting/EndpointStarter.cs index 682c7a9f6a..757a4da446 100644 --- a/src/NServiceBus.Core/Hosting/EndpointStarter.cs +++ b/src/NServiceBus.Core/Hosting/EndpointStarter.cs @@ -10,9 +10,10 @@ class EndpointStarter( IStartableEndpointWithExternallyManagedContainer startableEndpoint, IServiceProvider serviceProvider, object serviceKey, + object loggingSlot, KeyedServiceCollectionAdapter services) : IEndpointStarter { - public object LoggingSlot => serviceKey; + public object LoggingSlot => loggingSlot; public async ValueTask GetOrStart(CancellationToken cancellationToken = default) { @@ -31,7 +32,6 @@ public async ValueTask GetOrStart(CancellationToken cancellat } LoggingBridge.RegisterMicrosoftFactoryIfAvailable(serviceProvider, LoggingSlot); - using var _ = LoggingBridge.BeginScope(LoggingSlot); keyedServices = new KeyedServiceProviderAdapter(serviceProvider, serviceKey, services); @@ -54,7 +54,6 @@ public async ValueTask DisposeAsync() if (endpoint != null) { - using var _ = LoggingBridge.BeginScope(LoggingSlot); await endpoint.Stop().ConfigureAwait(false); } diff --git a/src/NServiceBus.Core/Hosting/HostingComponent.Configuration.cs b/src/NServiceBus.Core/Hosting/HostingComponent.Configuration.cs index bddea11ef9..5ac04fb72d 100644 --- a/src/NServiceBus.Core/Hosting/HostingComponent.Configuration.cs +++ b/src/NServiceBus.Core/Hosting/HostingComponent.Configuration.cs @@ -20,6 +20,7 @@ public static Configuration PrepareConfiguration(Settings settings, List a settings.StartupDiagnostics, settings.DiagnosticsPath, settings.HostDiagnosticsWriter, + settings.GetOrCreateEndpointLogSlot(), settings.EndpointName, serviceCollection, settings.ShouldRunInstallers, @@ -33,12 +34,13 @@ public static Configuration PrepareConfiguration(Settings settings, List a public class Configuration { - public Configuration(Settings settings, + internal Configuration(Settings settings, List availableTypes, CriticalError criticalError, StartupDiagnosticEntries startupDiagnostics, string? diagnosticsPath, Func? hostDiagnosticsWriter, + Logging.EndpointLogSlot endpointLogSlot, string endpointName, IServiceCollection services, bool shouldRunInstallers, @@ -52,6 +54,7 @@ public Configuration(Settings settings, StartupDiagnostics = startupDiagnostics; DiagnosticsPath = diagnosticsPath; HostDiagnosticsWriter = hostDiagnosticsWriter; + EndpointLogSlot = endpointLogSlot; EndpointName = endpointName; Services = services; ShouldRunInstallers = shouldRunInstallers; @@ -72,6 +75,8 @@ public Configuration(Settings settings, public Func? HostDiagnosticsWriter { get; } + internal Logging.EndpointLogSlot EndpointLogSlot { get; } + public string EndpointName { get; } public IServiceCollection Services { get; } diff --git a/src/NServiceBus.Core/Hosting/HostingComponent.Settings.cs b/src/NServiceBus.Core/Hosting/HostingComponent.Settings.cs index 5bba4f43f1..461fde7d34 100644 --- a/src/NServiceBus.Core/Hosting/HostingComponent.Settings.cs +++ b/src/NServiceBus.Core/Hosting/HostingComponent.Settings.cs @@ -5,6 +5,7 @@ namespace NServiceBus; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Logging; using Microsoft.Extensions.DependencyInjection; using Settings; using Support; @@ -45,6 +46,22 @@ public string DisplayName public string EndpointName => settings.EndpointName(); + public bool IsMultiHosted + { + get => settings.GetOrDefault(IsMultiHostedSettingsKey); + set => settings.Set(IsMultiHostedSettingsKey, value); + } + + public object? EndpointIdentifier + { + get + { + var value = settings.GetOrDefault(EndpointIdentifierSettingsKey); + return value == NullMarker.Value ? null : value; + } + set => settings.Set(EndpointIdentifierSettingsKey, value ?? NullMarker.Value); + } + public Dictionary Properties { get => settings.Get>(PropertiesSettingsKey); @@ -79,6 +96,25 @@ public bool ShouldRunInstallers set => settings.Set("Installers.Enable", value); } + internal void ConfigureMultiHostLogging(bool isMultiHosted, object? endpointIdentifier) + { + IsMultiHosted = isMultiHosted; + EndpointIdentifier = endpointIdentifier; + settings.Set(EndpointLogSlotSettingsKey, new EndpointLogSlot(EndpointName, endpointIdentifier)); + } + + internal EndpointLogSlot GetOrCreateEndpointLogSlot() + { + if (settings.TryGet(EndpointLogSlotSettingsKey, out EndpointLogSlot existingSlot)) + { + return existingSlot; + } + + var createdSlot = new EndpointLogSlot(EndpointName, EndpointIdentifier); + settings.Set(EndpointLogSlotSettingsKey, createdSlot); + return createdSlot; + } + internal void ApplyHostIdDefaultIfNeeded() { // We don't want to do settings.SetDefault() all the time, because the default uses MD5 which runs afoul of FIPS in such a way that cannot be worked around. @@ -105,8 +141,16 @@ internal void UpdateHost(string hostName) const string HostIdSettingsKey = "NServiceBus.HostInformation.HostId"; const string DisplayNameSettingsKey = "NServiceBus.HostInformation.DisplayName"; const string PropertiesSettingsKey = "NServiceBus.HostInformation.Properties"; + const string IsMultiHostedSettingsKey = "NServiceBus.Hosting.IsMultiHosted"; + const string EndpointIdentifierSettingsKey = "NServiceBus.Hosting.EndpointIdentifier"; + const string EndpointLogSlotSettingsKey = "NServiceBus.Hosting.EndpointLogSlot"; const string DiagnosticsPathSettingsKey = "Diagnostics.RootPath"; const string HostDiagnosticsWriterSettingsKey = "HostDiagnosticsWriter"; const string CustomCriticalErrorActionSettingsKey = "onCriticalErrorAction"; + + sealed class NullMarker + { + public static readonly NullMarker Value = new(); + } } } \ No newline at end of file diff --git a/src/NServiceBus.Core/Hosting/LoggingBridge.cs b/src/NServiceBus.Core/Hosting/LoggingBridge.cs index 1ab3c650e9..5917e7125d 100644 --- a/src/NServiceBus.Core/Hosting/LoggingBridge.cs +++ b/src/NServiceBus.Core/Hosting/LoggingBridge.cs @@ -9,8 +9,6 @@ namespace NServiceBus; static class LoggingBridge { - public static IDisposable BeginScope(object slot) => LogManager.BeginSlotScope(slot); - public static void RegisterMicrosoftFactoryIfAvailable(IServiceProvider serviceProvider, object slot) { var microsoftLoggerFactory = serviceProvider.GetService(); @@ -21,4 +19,4 @@ public static void RegisterMicrosoftFactoryIfAvailable(IServiceProvider serviceP LogManager.RegisterSlotFactory(slot, new MicrosoftLoggerFactoryAdapter(microsoftLoggerFactory)); } -} +} \ No newline at end of file diff --git a/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs index 460b3366d4..06e7cd3060 100644 --- a/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs +++ b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ public static void AddNServiceBusEndpoint( ArgumentNullException.ThrowIfNull(endpointConfiguration); var endpointName = endpointConfiguration.GetSettings().EndpointName(); + var hostingSettings = endpointConfiguration.GetSettings().Get(); var transport = endpointConfiguration.GetSettings().Get(); var registrations = GetExistingRegistrations(services); @@ -36,11 +37,14 @@ public static void AddNServiceBusEndpoint( ValidateAssemblyScanning(endpointConfiguration, endpointName, registrations); ValidateTransportReuse(transport, registrations); + hostingSettings.ConfigureMultiHostLogging(endpointIdentifier is not null, endpointIdentifier); + var endpointLogSlot = hostingSettings.GetOrCreateEndpointLogSlot(); + if (endpointIdentifier is null) { var startableEndpoint = EndpointWithExternallyManagedContainer.Create(endpointConfiguration, services); - services.AddSingleton(sp => new UnkeyedEndpointStarter(startableEndpoint, sp, endpointName)); + services.AddSingleton(sp => new UnkeyedEndpointStarter(startableEndpoint, sp, endpointLogSlot)); services.AddSingleton(sp => new NServiceBusHostedService(sp.GetRequiredService())); services.AddSingleton(sp => sp.GetRequiredService()); @@ -51,7 +55,7 @@ public static void AddNServiceBusEndpoint( var startableEndpoint = EndpointWithExternallyManagedContainer.Create(endpointConfiguration, keyedServices); services.AddKeyedSingleton(endpointIdentifier, (sp, _) => - new EndpointStarter(startableEndpoint, sp, endpointIdentifier, keyedServices)); + new EndpointStarter(startableEndpoint, sp, endpointIdentifier, endpointLogSlot, keyedServices)); services.AddSingleton(sp => new NServiceBusHostedService(sp.GetRequiredKeyedService(endpointIdentifier))); diff --git a/src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs b/src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs index 438a071195..2a33bff163 100644 --- a/src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs +++ b/src/NServiceBus.Core/Hosting/UnkeyedEndpointStarter.cs @@ -30,7 +30,6 @@ public async ValueTask GetOrStart(CancellationToken cancellat } LoggingBridge.RegisterMicrosoftFactoryIfAvailable(serviceProvider, LoggingSlot); - using var _ = LoggingBridge.BeginScope(LoggingSlot); endpoint = await startableEndpoint.Start(serviceProvider, cancellationToken).ConfigureAwait(false); @@ -49,7 +48,6 @@ public async ValueTask DisposeAsync() return; } - using var _ = LoggingBridge.BeginScope(LoggingSlot); await endpoint.Stop().ConfigureAwait(false); startSemaphore.Dispose(); } diff --git a/src/NServiceBus.Core/Logging/EndpointLogScopeState.cs b/src/NServiceBus.Core/Logging/EndpointLogScopeState.cs new file mode 100644 index 0000000000..778cd9a8a4 --- /dev/null +++ b/src/NServiceBus.Core/Logging/EndpointLogScopeState.cs @@ -0,0 +1,41 @@ +#nullable enable + +namespace NServiceBus.Logging; + +using System; +using System.Collections; +using System.Collections.Generic; + +sealed class EndpointLogScopeState(object endpointName, object? endpointIdentifier) : IReadOnlyList> +{ + public KeyValuePair this[int index] => + index switch + { + 0 => new KeyValuePair("Endpoint", endpointName), + 1 when endpointIdentifier is not null => new KeyValuePair("EndpointIdentifier", endpointIdentifier), + _ => throw new ArgumentOutOfRangeException(nameof(index)) + }; + + public int Count => endpointIdentifier is null ? 1 : 2; + + public IEnumerator> GetEnumerator() + { + yield return this[0]; + + if (endpointIdentifier is not null) + { + yield return this[1]; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public override string ToString() => endpointIdentifier is null + ? $"Endpoint = {endpointName}" + : $"Endpoint = {endpointName}, EndpointIdentifier = {endpointIdentifier}"; +} + +sealed class EndpointLogSlot(string endpointName, object? endpointIdentifier) +{ + public EndpointLogScopeState ScopeState { get; } = new(endpointName, endpointIdentifier); +} \ No newline at end of file diff --git a/src/NServiceBus.Core/Logging/LogManager.cs b/src/NServiceBus.Core/Logging/LogManager.cs index a6a7c5e004..1334f68299 100644 --- a/src/NServiceBus.Core/Logging/LogManager.cs +++ b/src/NServiceBus.Core/Logging/LogManager.cs @@ -71,7 +71,7 @@ internal static void RegisterSlotFactory(object slot, ILoggerFactory loggerFacto var slotKey = new SlotKey(slot); slotLoggerFactories[slotKey] = loggerFactory; - var slotContext = slotContexts.GetOrAdd(slotKey, static key => new SlotContext(key.Value)); + var slotContext = GetOrAddSlotContext(slotKey); using var _ = new SlotScope(slotContext); foreach (var logger in loggers.Values) @@ -85,22 +85,30 @@ internal static IDisposable BeginSlotScope(object slot) ArgumentNullException.ThrowIfNull(slot); var slotKey = new SlotKey(slot); - var slotContext = slotContexts.GetOrAdd(slotKey, static key => new SlotContext(key.Value)); + var slotContext = GetOrAddSlotContext(slotKey); return new SlotScope(slotContext); } - internal static bool TryGetCurrentEndpointIdentifier(out object endpointIdentifier) + internal static bool TryGetCurrentEndpointScopeState([NotNullWhen(true)] out EndpointLogScopeState? scopeState) { if (currentSlot.Value is null) { - endpointIdentifier = null!; + scopeState = null; return false; } - endpointIdentifier = currentSlot.Value.Identifier; + scopeState = currentSlot.Value.ScopeState; return true; } + static SlotContext GetOrAddSlotContext(SlotKey slotKey) => + slotContexts.GetOrAdd(slotKey, static key => new SlotContext(key.Value, CreateScopeState(key.Value))); + + static EndpointLogScopeState CreateScopeState(object slot) => + slot is EndpointLogSlot endpointSlot + ? endpointSlot.ScopeState + : new EndpointLogScopeState(slot, endpointIdentifier: null); + static bool TryGetSlotLoggerFactory(out SlotContext slotContext, out ILoggerFactory loggerFactory) { var current = currentSlot.Value; @@ -361,10 +369,11 @@ public SlotScope(SlotContext slot) readonly SlotContext? previousSlot; } - sealed class SlotContext(object identifier) + sealed class SlotContext(object identifier, EndpointLogScopeState scopeState) { public object Identifier { get; } = identifier; public SlotKey Key { get; } = new(identifier); + public EndpointLogScopeState ScopeState { get; } = scopeState; } readonly struct SlotKey(object value) : IEquatable diff --git a/src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs b/src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs index e7f12fcc66..5c723f6699 100644 --- a/src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs +++ b/src/NServiceBus.Core/Logging/MicrosoftLoggerFactoryAdapter.cs @@ -3,8 +3,6 @@ namespace NServiceBus.Logging; using System; -using System.Collections; -using System.Collections.Generic; using MicrosoftLoggerFactory = Microsoft.Extensions.Logging.ILoggerFactory; using MicrosoftLogger = Microsoft.Extensions.Logging.ILogger; using MicrosoftLogLevel = Microsoft.Extensions.Logging.LogLevel; @@ -55,12 +53,12 @@ void Log(MicrosoftLogLevel level, string? message, Exception? exception = null) IDisposable BeginScope() { - if (!LogManager.TryGetCurrentEndpointIdentifier(out var endpointIdentifier)) + if (!LogManager.TryGetCurrentEndpointScopeState(out var scopeState)) { return NullScope.Instance; } - return logger.BeginScope(new EndpointScope(endpointIdentifier)) ?? NullScope.Instance; + return logger.BeginScope(scopeState) ?? NullScope.Instance; } sealed class NullScope : IDisposable @@ -72,25 +70,5 @@ public void Dispose() } } - sealed class EndpointScope(object endpointIdentifier) : IReadOnlyList> - { - public KeyValuePair this[int index] => - index switch - { - 0 => new KeyValuePair("Endpoint", endpointIdentifier), - _ => throw new ArgumentOutOfRangeException(nameof(index)) - }; - - public int Count => 1; - - public IEnumerator> GetEnumerator() - { - yield return this[0]; - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - public override string ToString() => $"Endpoint = {endpointIdentifier}"; - } } } \ No newline at end of file diff --git a/src/NServiceBus.Core/Pipeline/MainPipelineExecutor.cs b/src/NServiceBus.Core/Pipeline/MainPipelineExecutor.cs index 07560a36c1..4a4c38ad89 100644 --- a/src/NServiceBus.Core/Pipeline/MainPipelineExecutor.cs +++ b/src/NServiceBus.Core/Pipeline/MainPipelineExecutor.cs @@ -5,6 +5,7 @@ namespace NServiceBus; using System; using System.Threading; using System.Threading.Tasks; +using Logging; using Microsoft.Extensions.DependencyInjection; using Pipeline; using Transport; @@ -15,6 +16,7 @@ class MainPipelineExecutor( MessageOperations messageOperations, INotificationSubscriptions receivePipelineNotification, IPipeline receivePipeline, + object endpointLogSlot, IActivityFactory activityFactory, IncomingPipelineMetrics incomingPipelineMetrics, EnvelopeUnwrapper envelopeUnwrapper) @@ -22,6 +24,8 @@ class MainPipelineExecutor( { public async Task Invoke(MessageContext messageContext, CancellationToken cancellationToken = default) { + using var _ = LogManager.BeginSlotScope(endpointLogSlot); + var pipelineStartedAt = DateTimeOffset.UtcNow; using var activity = activityFactory.StartIncomingPipelineActivity(messageContext); diff --git a/src/NServiceBus.Core/Pipeline/SatellitePipelineExecutor.cs b/src/NServiceBus.Core/Pipeline/SatellitePipelineExecutor.cs index b95b874188..d016a1fcd1 100644 --- a/src/NServiceBus.Core/Pipeline/SatellitePipelineExecutor.cs +++ b/src/NServiceBus.Core/Pipeline/SatellitePipelineExecutor.cs @@ -5,12 +5,15 @@ namespace NServiceBus; using System; using System.Threading; using System.Threading.Tasks; +using Logging; using Transport; -class SatellitePipelineExecutor(IServiceProvider builder, SatelliteDefinition definition) : IPipelineExecutor +class SatellitePipelineExecutor(IServiceProvider builder, SatelliteDefinition definition, object endpointLogSlot) : IPipelineExecutor { public Task Invoke(MessageContext messageContext, CancellationToken cancellationToken = default) { + using var _ = LogManager.BeginSlotScope(endpointLogSlot); + messageContext.Extensions.Set(messageContext.TransportTransaction); return definition.OnMessage(builder, messageContext, cancellationToken); diff --git a/src/NServiceBus.Core/Receiving/LogWrappedMessageReceiver.cs b/src/NServiceBus.Core/Receiving/LogWrappedMessageReceiver.cs new file mode 100644 index 0000000000..4fb0a3c243 --- /dev/null +++ b/src/NServiceBus.Core/Receiving/LogWrappedMessageReceiver.cs @@ -0,0 +1,45 @@ +#nullable enable + +namespace NServiceBus; + +using System; +using System.Threading; +using System.Threading.Tasks; +using NServiceBus.Logging; +using NServiceBus.Transport; + +class LogWrappedMessageReceiver(IMessageReceiver receiver, object endpointLogSlot) : IMessageReceiver +{ + public ISubscriptionManager Subscriptions => receiver.Subscriptions; + public string Id => receiver.Id; + public string ReceiveAddress => receiver.ReceiveAddress; + + public Task ChangeConcurrency(PushRuntimeSettings limitations, CancellationToken cancellationToken = default) => + receiver.ChangeConcurrency(limitations, cancellationToken); + + public Task StartReceive(CancellationToken cancellationToken = default) => + receiver.StartReceive(cancellationToken); + + public Task StopReceive(CancellationToken cancellationToken = default) => + receiver.StopReceive(cancellationToken); + + public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnError onError, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(onMessage); + ArgumentNullException.ThrowIfNull(onError); + + return receiver.Initialize(limitations, ScopedOnMessage, ScopedOnError, cancellationToken); + + async Task ScopedOnMessage(MessageContext messageContext, CancellationToken ct) + { + using var _ = LogManager.BeginSlotScope(endpointLogSlot); + await onMessage(messageContext, ct).ConfigureAwait(false); + } + + async Task ScopedOnError(ErrorContext errorContext, CancellationToken ct) + { + using var _ = LogManager.BeginSlotScope(endpointLogSlot); + return await onError(errorContext, ct).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/NServiceBus.Core/Receiving/ReceiveComponent.cs b/src/NServiceBus.Core/Receiving/ReceiveComponent.cs index 51db4ebc0b..381fec05ff 100644 --- a/src/NServiceBus.Core/Receiving/ReceiveComponent.cs +++ b/src/NServiceBus.Core/Receiving/ReceiveComponent.cs @@ -16,10 +16,11 @@ namespace NServiceBus; partial class ReceiveComponent { - ReceiveComponent(Configuration configuration, IActivityFactory activityFactory) + ReceiveComponent(Configuration configuration, IActivityFactory activityFactory, object endpointLogSlot) { this.configuration = configuration; this.activityFactory = activityFactory; + this.endpointLogSlot = endpointLogSlot; } public static ReceiveComponent Configure( @@ -31,10 +32,10 @@ public static ReceiveComponent Configure( if (configuration.IsSendOnlyEndpoint) { configuration.TransportSeam.Configure([]); - return new ReceiveComponent(configuration, hostingConfiguration.ActivityFactory); + return new ReceiveComponent(configuration, hostingConfiguration.ActivityFactory, hostingConfiguration.EndpointLogSlot); } - var receiveComponent = new ReceiveComponent(configuration, hostingConfiguration.ActivityFactory); + var receiveComponent = new ReceiveComponent(configuration, hostingConfiguration.ActivityFactory, hostingConfiguration.EndpointLogSlot); hostingConfiguration.Services.AddSingleton(sp => { @@ -148,13 +149,13 @@ public async Task Initialize( return; } - var mainPump = CreateReceiver(consecutiveFailuresConfiguration, transportInfrastructure.Receivers[MainReceiverId]); + var mainPump = CreateReceiver(consecutiveFailuresConfiguration, transportInfrastructure.Receivers[MainReceiverId], endpointLogSlot); var receivePipeline = pipelineComponent.CreatePipeline(builder); var pipelineMetrics = builder.GetRequiredService(); var envelopeUnwrapper = envelopeComponent.CreateUnwrapper(builder); - var mainPipelineExecutor = new MainPipelineExecutor(builder, pipelineCache, messageOperations, configuration.PipelineCompletedSubscribers, receivePipeline, activityFactory, pipelineMetrics, envelopeUnwrapper); + var mainPipelineExecutor = new MainPipelineExecutor(builder, pipelineCache, messageOperations, configuration.PipelineCompletedSubscribers, receivePipeline, endpointLogSlot, activityFactory, pipelineMetrics, envelopeUnwrapper); var recoverabilityPipelineExecutor = recoverabilityComponent.CreateRecoverabilityPipelineExecutor( builder, @@ -172,7 +173,7 @@ await mainPump.Initialize( if (transportInfrastructure.Receivers.TryGetValue(InstanceSpecificReceiverId, out var instanceSpecificPump)) { - var instancePump = CreateReceiver(consecutiveFailuresConfiguration, instanceSpecificPump); + var instancePump = CreateReceiver(consecutiveFailuresConfiguration, instanceSpecificPump, endpointLogSlot); await instancePump.Initialize( configuration.PushRuntimeSettings, @@ -187,8 +188,8 @@ await instancePump.Initialize( { try { - var satellitePump = CreateReceiver(consecutiveFailuresConfiguration, transportInfrastructure.Receivers[satellite.Name]); - var satellitePipeline = new SatellitePipelineExecutor(builder, satellite); + var satellitePump = CreateReceiver(consecutiveFailuresConfiguration, transportInfrastructure.Receivers[satellite.Name], endpointLogSlot); + var satellitePipeline = new SatellitePipelineExecutor(builder, satellite, endpointLogSlot); var satelliteRecoverabilityExecutor = recoverabilityComponent.CreateSatelliteRecoverabilityExecutor(builder, satellite.RecoverabilityPolicy); await satellitePump.Initialize( @@ -210,6 +211,8 @@ await satellitePump.Initialize( public async Task Start(CancellationToken cancellationToken = default) { + using var _ = LogManager.BeginSlotScope(endpointLogSlot); + foreach (var messageReceiver in receivers) { try @@ -227,6 +230,8 @@ public async Task Start(CancellationToken cancellationToken = default) public Task Stop(CancellationToken cancellationToken = default) { + using var _ = LogManager.BeginSlotScope(endpointLogSlot); + var receiverStopTasks = receivers.Select(async receiver => { try @@ -244,11 +249,18 @@ public Task Stop(CancellationToken cancellationToken = default) return Task.WhenAll(receiverStopTasks); } - static IMessageReceiver CreateReceiver(ConsecutiveFailuresConfiguration consecutiveFailuresConfiguration, IMessageReceiver receiver) - => consecutiveFailuresConfiguration.RateLimitSettings is not null ? new WrappedMessageReceiver(consecutiveFailuresConfiguration, receiver) : receiver; + static LogWrappedMessageReceiver CreateReceiver(ConsecutiveFailuresConfiguration consecutiveFailuresConfiguration, IMessageReceiver receiver, object endpointLogSlot) + { + var effectiveReceiver = consecutiveFailuresConfiguration.RateLimitSettings is not null + ? new WrappedMessageReceiver(consecutiveFailuresConfiguration, receiver) + : receiver; + + return new LogWrappedMessageReceiver(effectiveReceiver, endpointLogSlot); + } readonly Configuration configuration; readonly IActivityFactory activityFactory; + readonly object endpointLogSlot; readonly List receivers = []; const string MainReceiverId = "Main"; diff --git a/src/NServiceBus.Core/StartableEndpoint.cs b/src/NServiceBus.Core/StartableEndpoint.cs index 36a414b47e..6397c36658 100644 --- a/src/NServiceBus.Core/StartableEndpoint.cs +++ b/src/NServiceBus.Core/StartableEndpoint.cs @@ -6,6 +6,7 @@ namespace NServiceBus; using System.Threading; using System.Threading.Tasks; using Features; +using Logging; using Settings; using Transport; @@ -22,10 +23,16 @@ class StartableEndpoint( IServiceProvider serviceProvider, bool serviceProviderIsExternallyManaged) { - public Task RunInstallers(CancellationToken cancellationToken = default) => hostingComponent.RunInstallers(serviceProvider, cancellationToken); + public async Task RunInstallers(CancellationToken cancellationToken = default) + { + using var _ = LogManager.BeginSlotScope(hostingComponent.Config.EndpointLogSlot); + await hostingComponent.RunInstallers(serviceProvider, cancellationToken).ConfigureAwait(false); + } public async Task Setup(CancellationToken cancellationToken = default) { + using var _ = LogManager.BeginSlotScope(hostingComponent.Config.EndpointLogSlot); + transportInfrastructure = await transportSeam.CreateTransportInfrastructure(serviceProvider, cancellationToken).ConfigureAwait(false); var pipelineCache = pipelineComponent.BuildPipelineCache(serviceProvider); @@ -49,6 +56,8 @@ void AddSendingQueueManifest() public async Task Start(CancellationToken cancellationToken = default) { + using var _ = LogManager.BeginSlotScope(hostingComponent.Config.EndpointLogSlot); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { AppDomain.CurrentDomain.SetPrincipalPolicy(PrincipalPolicy.WindowsPrincipal); @@ -60,7 +69,7 @@ public async Task Start(CancellationToken cancellationToken = // when the service provider is externally managed it is null in the running endpoint instance IServiceProvider provider = serviceProviderIsExternallyManaged ? null : serviceProvider; - var runningInstance = new RunningEndpointInstance(settings, receiveComponent, featureComponent, messageSession, transportInfrastructure, stoppingTokenSource, provider); + var runningInstance = new RunningEndpointInstance(settings, receiveComponent, featureComponent, messageSession, transportInfrastructure, stoppingTokenSource, provider, hostingComponent.Config.EndpointLogSlot); hostingComponent.SetupCriticalErrors(runningInstance, cancellationToken); diff --git a/src/NServiceBus.Core/Unicast/RunningEndpointInstance.cs b/src/NServiceBus.Core/Unicast/RunningEndpointInstance.cs index 60d6c5fc76..6869d8af6c 100644 --- a/src/NServiceBus.Core/Unicast/RunningEndpointInstance.cs +++ b/src/NServiceBus.Core/Unicast/RunningEndpointInstance.cs @@ -16,10 +16,13 @@ class RunningEndpointInstance(SettingsHolder settings, IMessageSession messageSession, TransportInfrastructure transportInfrastructure, CancellationTokenSource stoppingTokenSource, - IServiceProvider? serviceProvider) : IEndpointInstance + IServiceProvider? serviceProvider, + object endpointLogSlot) : IEndpointInstance { public async Task Stop(CancellationToken cancellationToken = default) { + using var _ = LogManager.BeginSlotScope(endpointLogSlot); + if (status == Status.Stopped) { return; From c1ae30d8d440c549e157fd7923461993cccd4387 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Wed, 25 Feb 2026 12:17:14 +0100 Subject: [PATCH 10/12] Take satellites and more into account --- .../Logging/EndpointLoggingScopeTests.cs | 58 +++++++++++++ .../Logging/EndpointLogScopeState.cs | 85 ++++++++++++++++++- src/NServiceBus.Core/Logging/LogManager.cs | 6 +- .../Pipeline/SatellitePipelineExecutor.cs | 4 +- .../Receiving/ReceiveComponent.cs | 42 +++++++-- 5 files changed, 183 insertions(+), 12 deletions(-) diff --git a/src/NServiceBus.Core.Tests/Logging/EndpointLoggingScopeTests.cs b/src/NServiceBus.Core.Tests/Logging/EndpointLoggingScopeTests.cs index e7162c6b3d..85bd8157e3 100644 --- a/src/NServiceBus.Core.Tests/Logging/EndpointLoggingScopeTests.cs +++ b/src/NServiceBus.Core.Tests/Logging/EndpointLoggingScopeTests.cs @@ -61,6 +61,64 @@ public void Should_include_only_endpoint_name_when_identifier_is_not_provided() } } + [Test] + public void Should_include_satellite_name_for_satellite_scope() + { + var loggerFactory = new CollectingMicrosoftLoggerFactory(); + var endpointSlot = new EndpointLogSlot("Shipping", "green"); + var satelliteSlot = new EndpointSatelliteLogSlot(endpointSlot, "TimeoutMigration"); + LogManager.RegisterSlotFactory(satelliteSlot, new MicrosoftLoggerFactoryAdapter(loggerFactory)); + + var logger = LogManager.GetLogger($"{nameof(EndpointLoggingScopeTests)}-{Guid.NewGuid():N}"); + + using (LogManager.BeginSlotScope(satelliteSlot)) + { + logger.Info("message"); + } + + var scope = loggerFactory.Logger.CapturedScopes.Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(scope.Count, Is.EqualTo(3)); + Assert.That(scope[0].Key, Is.EqualTo("Endpoint")); + Assert.That(scope[0].Value, Is.EqualTo("Shipping")); + Assert.That(scope[1].Key, Is.EqualTo("EndpointIdentifier")); + Assert.That(scope[1].Value, Is.EqualTo("green")); + Assert.That(scope[2].Key, Is.EqualTo("Satellite")); + Assert.That(scope[2].Value, Is.EqualTo("TimeoutMigration")); + } + } + + [Test] + public void Should_include_receiver_name_for_instance_specific_receiver_scope() + { + var loggerFactory = new CollectingMicrosoftLoggerFactory(); + var endpointSlot = new EndpointLogSlot("Shipping", "green"); + var receiverSlot = new EndpointReceiverLogSlot(endpointSlot, "InstanceSpecific"); + LogManager.RegisterSlotFactory(receiverSlot, new MicrosoftLoggerFactoryAdapter(loggerFactory)); + + var logger = LogManager.GetLogger($"{nameof(EndpointLoggingScopeTests)}-{Guid.NewGuid():N}"); + + using (LogManager.BeginSlotScope(receiverSlot)) + { + logger.Info("message"); + } + + var scope = loggerFactory.Logger.CapturedScopes.Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(scope.Count, Is.EqualTo(3)); + Assert.That(scope[0].Key, Is.EqualTo("Endpoint")); + Assert.That(scope[0].Value, Is.EqualTo("Shipping")); + Assert.That(scope[1].Key, Is.EqualTo("EndpointIdentifier")); + Assert.That(scope[1].Value, Is.EqualTo("green")); + Assert.That(scope[2].Key, Is.EqualTo("Receiver")); + Assert.That(scope[2].Value, Is.EqualTo("InstanceSpecific")); + } + } + sealed class CollectingMicrosoftLoggerFactory : Microsoft.Extensions.Logging.ILoggerFactory { public CollectingMicrosoftLogger Logger { get; } = new(); diff --git a/src/NServiceBus.Core/Logging/EndpointLogScopeState.cs b/src/NServiceBus.Core/Logging/EndpointLogScopeState.cs index 778cd9a8a4..73165ec280 100644 --- a/src/NServiceBus.Core/Logging/EndpointLogScopeState.cs +++ b/src/NServiceBus.Core/Logging/EndpointLogScopeState.cs @@ -8,15 +8,49 @@ namespace NServiceBus.Logging; sealed class EndpointLogScopeState(object endpointName, object? endpointIdentifier) : IReadOnlyList> { + public static EndpointLogScopeState ForSatellite(object endpointName, object? endpointIdentifier, string satelliteName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(satelliteName); + return new EndpointLogScopeState(endpointName, endpointIdentifier, receiverName: null, satelliteName); + } + + public static EndpointLogScopeState ForReceiver(object endpointName, object? endpointIdentifier, string receiverName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(receiverName); + return new EndpointLogScopeState(endpointName, endpointIdentifier, receiverName, satelliteName: null); + } + public KeyValuePair this[int index] => index switch { 0 => new KeyValuePair("Endpoint", endpointName), 1 when endpointIdentifier is not null => new KeyValuePair("EndpointIdentifier", endpointIdentifier), + 1 when receiverName is not null => new KeyValuePair("Receiver", receiverName), + 1 when satelliteName is not null => new KeyValuePair("Satellite", satelliteName), + 2 when endpointIdentifier is not null && receiverName is not null => new KeyValuePair("Receiver", receiverName), + 2 when endpointIdentifier is not null && satelliteName is not null => new KeyValuePair("Satellite", satelliteName), + 3 when endpointIdentifier is not null && receiverName is not null && satelliteName is not null => new KeyValuePair("Satellite", satelliteName), _ => throw new ArgumentOutOfRangeException(nameof(index)) }; - public int Count => endpointIdentifier is null ? 1 : 2; + public int Count + { + get + { + var count = endpointIdentifier is null ? 1 : 2; + if (satelliteName is not null) + { + count++; + } + + if (receiverName is not null) + { + count++; + } + + return count; + } + } public IEnumerator> GetEnumerator() { @@ -26,16 +60,61 @@ sealed class EndpointLogScopeState(object endpointName, object? endpointIdentifi { yield return this[1]; } + + if (receiverName is not null) + { + yield return this[endpointIdentifier is null ? 1 : 2]; + } + + if (satelliteName is not null) + { + var index = endpointIdentifier is null + ? receiverName is null ? 1 : 2 + : receiverName is null ? 2 : 3; + yield return this[index]; + } } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public override string ToString() => endpointIdentifier is null - ? $"Endpoint = {endpointName}" - : $"Endpoint = {endpointName}, EndpointIdentifier = {endpointIdentifier}"; + ? satelliteName is null + ? $"Endpoint = {endpointName}" + : receiverName is null + ? $"Endpoint = {endpointName}, Satellite = {satelliteName}" + : $"Endpoint = {endpointName}, Receiver = {receiverName}, Satellite = {satelliteName}" + : satelliteName is null + ? receiverName is null + ? $"Endpoint = {endpointName}, EndpointIdentifier = {endpointIdentifier}" + : $"Endpoint = {endpointName}, EndpointIdentifier = {endpointIdentifier}, Receiver = {receiverName}" + : receiverName is null + ? $"Endpoint = {endpointName}, EndpointIdentifier = {endpointIdentifier}, Satellite = {satelliteName}" + : $"Endpoint = {endpointName}, EndpointIdentifier = {endpointIdentifier}, Receiver = {receiverName}, Satellite = {satelliteName}"; + + EndpointLogScopeState(object endpointName, object? endpointIdentifier, string? receiverName, string? satelliteName) + : this(endpointName, endpointIdentifier) + { + this.receiverName = receiverName; + this.satelliteName = satelliteName; + } + + readonly string? receiverName; + readonly string? satelliteName; } sealed class EndpointLogSlot(string endpointName, object? endpointIdentifier) { + public object EndpointName { get; } = endpointName; + public object? EndpointIdentifier { get; } = endpointIdentifier; public EndpointLogScopeState ScopeState { get; } = new(endpointName, endpointIdentifier); +} + +sealed class EndpointSatelliteLogSlot(EndpointLogSlot endpointSlot, string satelliteName) +{ + public EndpointLogScopeState ScopeState { get; } = EndpointLogScopeState.ForSatellite(endpointSlot.EndpointName, endpointSlot.EndpointIdentifier, satelliteName); +} + +sealed class EndpointReceiverLogSlot(EndpointLogSlot endpointSlot, string receiverName) +{ + public EndpointLogScopeState ScopeState { get; } = EndpointLogScopeState.ForReceiver(endpointSlot.EndpointName, endpointSlot.EndpointIdentifier, receiverName); } \ No newline at end of file diff --git a/src/NServiceBus.Core/Logging/LogManager.cs b/src/NServiceBus.Core/Logging/LogManager.cs index 1334f68299..5d47cc76dd 100644 --- a/src/NServiceBus.Core/Logging/LogManager.cs +++ b/src/NServiceBus.Core/Logging/LogManager.cs @@ -107,6 +107,10 @@ static SlotContext GetOrAddSlotContext(SlotKey slotKey) => static EndpointLogScopeState CreateScopeState(object slot) => slot is EndpointLogSlot endpointSlot ? endpointSlot.ScopeState + : slot is EndpointReceiverLogSlot receiverSlot + ? receiverSlot.ScopeState + : slot is EndpointSatelliteLogSlot satelliteSlot + ? satelliteSlot.ScopeState : new EndpointLogScopeState(slot, endpointIdentifier: null); static bool TryGetSlotLoggerFactory(out SlotContext slotContext, out ILoggerFactory loggerFactory) @@ -393,4 +397,4 @@ readonly struct SlotKey(object value) : IEquatable static readonly ConcurrentDictionary loggers = new(StringComparer.Ordinal); static readonly ConcurrentDictionary slotContexts = new(); static readonly ConcurrentDictionary slotLoggerFactories = new(); -} \ No newline at end of file +} diff --git a/src/NServiceBus.Core/Pipeline/SatellitePipelineExecutor.cs b/src/NServiceBus.Core/Pipeline/SatellitePipelineExecutor.cs index d016a1fcd1..7e3881e429 100644 --- a/src/NServiceBus.Core/Pipeline/SatellitePipelineExecutor.cs +++ b/src/NServiceBus.Core/Pipeline/SatellitePipelineExecutor.cs @@ -8,11 +8,11 @@ namespace NServiceBus; using Logging; using Transport; -class SatellitePipelineExecutor(IServiceProvider builder, SatelliteDefinition definition, object endpointLogSlot) : IPipelineExecutor +class SatellitePipelineExecutor(IServiceProvider builder, SatelliteDefinition definition, object processingLogSlot) : IPipelineExecutor { public Task Invoke(MessageContext messageContext, CancellationToken cancellationToken = default) { - using var _ = LogManager.BeginSlotScope(endpointLogSlot); + using var _ = LogManager.BeginSlotScope(processingLogSlot); messageContext.Extensions.Set(messageContext.TransportTransaction); diff --git a/src/NServiceBus.Core/Receiving/ReceiveComponent.cs b/src/NServiceBus.Core/Receiving/ReceiveComponent.cs index 381fec05ff..1163a0af80 100644 --- a/src/NServiceBus.Core/Receiving/ReceiveComponent.cs +++ b/src/NServiceBus.Core/Receiving/ReceiveComponent.cs @@ -149,13 +149,14 @@ public async Task Initialize( return; } - var mainPump = CreateReceiver(consecutiveFailuresConfiguration, transportInfrastructure.Receivers[MainReceiverId], endpointLogSlot); + var mainProcessingLogSlot = CreateReceiverProcessingLogSlot(endpointLogSlot, MainReceiverId); + var mainPump = CreateReceiver(consecutiveFailuresConfiguration, transportInfrastructure.Receivers[MainReceiverId], mainProcessingLogSlot); var receivePipeline = pipelineComponent.CreatePipeline(builder); var pipelineMetrics = builder.GetRequiredService(); var envelopeUnwrapper = envelopeComponent.CreateUnwrapper(builder); - var mainPipelineExecutor = new MainPipelineExecutor(builder, pipelineCache, messageOperations, configuration.PipelineCompletedSubscribers, receivePipeline, endpointLogSlot, activityFactory, pipelineMetrics, envelopeUnwrapper); + var mainPipelineExecutor = new MainPipelineExecutor(builder, pipelineCache, messageOperations, configuration.PipelineCompletedSubscribers, receivePipeline, mainProcessingLogSlot, activityFactory, pipelineMetrics, envelopeUnwrapper); var recoverabilityPipelineExecutor = recoverabilityComponent.CreateRecoverabilityPipelineExecutor( builder, @@ -173,11 +174,13 @@ await mainPump.Initialize( if (transportInfrastructure.Receivers.TryGetValue(InstanceSpecificReceiverId, out var instanceSpecificPump)) { - var instancePump = CreateReceiver(consecutiveFailuresConfiguration, instanceSpecificPump, endpointLogSlot); + var instanceProcessingLogSlot = CreateReceiverProcessingLogSlot(endpointLogSlot, InstanceSpecificReceiverId); + var instancePump = CreateReceiver(consecutiveFailuresConfiguration, instanceSpecificPump, instanceProcessingLogSlot); + var instancePipelineExecutor = new MainPipelineExecutor(builder, pipelineCache, messageOperations, configuration.PipelineCompletedSubscribers, receivePipeline, instanceProcessingLogSlot, activityFactory, pipelineMetrics, envelopeUnwrapper); await instancePump.Initialize( configuration.PushRuntimeSettings, - mainPipelineExecutor.Invoke, + instancePipelineExecutor.Invoke, recoverabilityPipelineExecutor.Invoke, cancellationToken).ConfigureAwait(false); @@ -188,8 +191,10 @@ await instancePump.Initialize( { try { - var satellitePump = CreateReceiver(consecutiveFailuresConfiguration, transportInfrastructure.Receivers[satellite.Name], endpointLogSlot); - var satellitePipeline = new SatellitePipelineExecutor(builder, satellite, endpointLogSlot); + var satelliteLogSlot = CreateSatelliteProcessingLogSlot(endpointLogSlot, satellite.Name); + var satellitePump = CreateReceiver(consecutiveFailuresConfiguration, transportInfrastructure.Receivers[satellite.Name], satelliteLogSlot); + + var satellitePipeline = new SatellitePipelineExecutor(builder, satellite, satelliteLogSlot); var satelliteRecoverabilityExecutor = recoverabilityComponent.CreateSatelliteRecoverabilityExecutor(builder, satellite.RecoverabilityPolicy); await satellitePump.Initialize( @@ -258,6 +263,31 @@ static LogWrappedMessageReceiver CreateReceiver(ConsecutiveFailuresConfiguration return new LogWrappedMessageReceiver(effectiveReceiver, endpointLogSlot); } + static object CreateReceiverProcessingLogSlot(object endpointLogSlot, string receiverId) + { + if (endpointLogSlot is not Logging.EndpointLogSlot endpointSlot) + { + return endpointLogSlot; + } + + if (receiverId == InstanceSpecificReceiverId) + { + return new Logging.EndpointReceiverLogSlot(endpointSlot, receiverId); + } + + return endpointLogSlot; + } + + static object CreateSatelliteProcessingLogSlot(object endpointLogSlot, string satelliteName) + { + if (endpointLogSlot is not Logging.EndpointLogSlot endpointSlot) + { + return endpointLogSlot; + } + + return new Logging.EndpointSatelliteLogSlot(endpointSlot, satelliteName); + } + readonly Configuration configuration; readonly IActivityFactory activityFactory; readonly object endpointLogSlot; From 9b5f9e14834adb3117836046444e2106b406faee Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Wed, 25 Feb 2026 12:28:43 +0100 Subject: [PATCH 11/12] Align slot state --- .../LogWrappedMessageReceiverTests.cs | 4 +- .../Logging/EndpointLogScopeState.cs | 123 +++++++----------- src/NServiceBus.Core/Logging/LogManager.cs | 16 +-- 3 files changed, 53 insertions(+), 90 deletions(-) diff --git a/src/NServiceBus.Core.Tests/Receiving/LogWrappedMessageReceiverTests.cs b/src/NServiceBus.Core.Tests/Receiving/LogWrappedMessageReceiverTests.cs index 7141f35594..5b92cbf99c 100644 --- a/src/NServiceBus.Core.Tests/Receiving/LogWrappedMessageReceiverTests.cs +++ b/src/NServiceBus.Core.Tests/Receiving/LogWrappedMessageReceiverTests.cs @@ -20,7 +20,7 @@ public async Task Should_scope_on_message_callback() var receiver = new TestReceiver(); var wrapped = new LogWrappedMessageReceiver(receiver, slot); - EndpointLogScopeState? scope = null; + LogScopeState? scope = null; await wrapped.Initialize(new PushRuntimeSettings(1), (messageContext, ct) => { @@ -55,7 +55,7 @@ public async Task Should_scope_on_error_callback() var receiver = new TestReceiver(); var wrapped = new LogWrappedMessageReceiver(receiver, slot); - EndpointLogScopeState? scope = null; + LogScopeState? scope = null; await wrapped.Initialize(new PushRuntimeSettings(1), (messageContext, ct) => { diff --git a/src/NServiceBus.Core/Logging/EndpointLogScopeState.cs b/src/NServiceBus.Core/Logging/EndpointLogScopeState.cs index 73165ec280..b305194407 100644 --- a/src/NServiceBus.Core/Logging/EndpointLogScopeState.cs +++ b/src/NServiceBus.Core/Logging/EndpointLogScopeState.cs @@ -6,53 +6,35 @@ namespace NServiceBus.Logging; using System.Collections; using System.Collections.Generic; -sealed class EndpointLogScopeState(object endpointName, object? endpointIdentifier) : IReadOnlyList> +abstract class LogScopeState : IReadOnlyList> { - public static EndpointLogScopeState ForSatellite(object endpointName, object? endpointIdentifier, string satelliteName) - { - ArgumentException.ThrowIfNullOrWhiteSpace(satelliteName); - return new EndpointLogScopeState(endpointName, endpointIdentifier, receiverName: null, satelliteName); - } + public abstract KeyValuePair this[int index] { get; } - public static EndpointLogScopeState ForReceiver(object endpointName, object? endpointIdentifier, string receiverName) - { - ArgumentException.ThrowIfNullOrWhiteSpace(receiverName); - return new EndpointLogScopeState(endpointName, endpointIdentifier, receiverName, satelliteName: null); - } + public abstract int Count { get; } + + public abstract IEnumerator> GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} - public KeyValuePair this[int index] => +abstract class LogSlot +{ + public abstract LogScopeState ScopeState { get; } +} + +sealed class EndpointLogScopeState(object endpointName, object? endpointIdentifier) : LogScopeState +{ + public override KeyValuePair this[int index] => index switch { 0 => new KeyValuePair("Endpoint", endpointName), 1 when endpointIdentifier is not null => new KeyValuePair("EndpointIdentifier", endpointIdentifier), - 1 when receiverName is not null => new KeyValuePair("Receiver", receiverName), - 1 when satelliteName is not null => new KeyValuePair("Satellite", satelliteName), - 2 when endpointIdentifier is not null && receiverName is not null => new KeyValuePair("Receiver", receiverName), - 2 when endpointIdentifier is not null && satelliteName is not null => new KeyValuePair("Satellite", satelliteName), - 3 when endpointIdentifier is not null && receiverName is not null && satelliteName is not null => new KeyValuePair("Satellite", satelliteName), _ => throw new ArgumentOutOfRangeException(nameof(index)) }; - public int Count - { - get - { - var count = endpointIdentifier is null ? 1 : 2; - if (satelliteName is not null) - { - count++; - } - - if (receiverName is not null) - { - count++; - } - - return count; - } - } + public override int Count => endpointIdentifier is null ? 1 : 2; - public IEnumerator> GetEnumerator() + public override IEnumerator> GetEnumerator() { yield return this[0]; @@ -60,61 +42,48 @@ public int Count { yield return this[1]; } + } - if (receiverName is not null) - { - yield return this[endpointIdentifier is null ? 1 : 2]; - } + public override string ToString() => endpointIdentifier is null + ? $"Endpoint = {endpointName}" + : $"Endpoint = {endpointName}, EndpointIdentifier = {endpointIdentifier}"; +} - if (satelliteName is not null) - { - var index = endpointIdentifier is null - ? receiverName is null ? 1 : 2 - : receiverName is null ? 2 : 3; - yield return this[index]; - } - } +sealed class ExtendedLogScopeState(LogScopeState parentScope, string key, object value) : LogScopeState +{ + public override KeyValuePair this[int index] => + index == parentScope.Count + ? extra + : parentScope[index]; - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public override int Count => parentScope.Count + 1; - public override string ToString() => endpointIdentifier is null - ? satelliteName is null - ? $"Endpoint = {endpointName}" - : receiverName is null - ? $"Endpoint = {endpointName}, Satellite = {satelliteName}" - : $"Endpoint = {endpointName}, Receiver = {receiverName}, Satellite = {satelliteName}" - : satelliteName is null - ? receiverName is null - ? $"Endpoint = {endpointName}, EndpointIdentifier = {endpointIdentifier}" - : $"Endpoint = {endpointName}, EndpointIdentifier = {endpointIdentifier}, Receiver = {receiverName}" - : receiverName is null - ? $"Endpoint = {endpointName}, EndpointIdentifier = {endpointIdentifier}, Satellite = {satelliteName}" - : $"Endpoint = {endpointName}, EndpointIdentifier = {endpointIdentifier}, Receiver = {receiverName}, Satellite = {satelliteName}"; - - EndpointLogScopeState(object endpointName, object? endpointIdentifier, string? receiverName, string? satelliteName) - : this(endpointName, endpointIdentifier) + public override IEnumerator> GetEnumerator() { - this.receiverName = receiverName; - this.satelliteName = satelliteName; + for (var i = 0; i < parentScope.Count; i++) + { + yield return parentScope[i]; + } + + yield return extra; } - readonly string? receiverName; - readonly string? satelliteName; + public override string ToString() => $"{parentScope}, {key} = {value}"; + + readonly KeyValuePair extra = new(key, value); } -sealed class EndpointLogSlot(string endpointName, object? endpointIdentifier) +sealed class EndpointLogSlot(string endpointName, object? endpointIdentifier) : LogSlot { - public object EndpointName { get; } = endpointName; - public object? EndpointIdentifier { get; } = endpointIdentifier; - public EndpointLogScopeState ScopeState { get; } = new(endpointName, endpointIdentifier); + public override LogScopeState ScopeState { get; } = new EndpointLogScopeState(endpointName, endpointIdentifier); } -sealed class EndpointSatelliteLogSlot(EndpointLogSlot endpointSlot, string satelliteName) +sealed class EndpointSatelliteLogSlot(EndpointLogSlot endpointSlot, string satelliteName) : LogSlot { - public EndpointLogScopeState ScopeState { get; } = EndpointLogScopeState.ForSatellite(endpointSlot.EndpointName, endpointSlot.EndpointIdentifier, satelliteName); + public override LogScopeState ScopeState { get; } = new ExtendedLogScopeState(endpointSlot.ScopeState, "Satellite", satelliteName); } -sealed class EndpointReceiverLogSlot(EndpointLogSlot endpointSlot, string receiverName) +sealed class EndpointReceiverLogSlot(EndpointLogSlot endpointSlot, string receiverName) : LogSlot { - public EndpointLogScopeState ScopeState { get; } = EndpointLogScopeState.ForReceiver(endpointSlot.EndpointName, endpointSlot.EndpointIdentifier, receiverName); + public override LogScopeState ScopeState { get; } = new ExtendedLogScopeState(endpointSlot.ScopeState, "Receiver", receiverName); } \ No newline at end of file diff --git a/src/NServiceBus.Core/Logging/LogManager.cs b/src/NServiceBus.Core/Logging/LogManager.cs index 5d47cc76dd..b6afc78d5d 100644 --- a/src/NServiceBus.Core/Logging/LogManager.cs +++ b/src/NServiceBus.Core/Logging/LogManager.cs @@ -89,7 +89,7 @@ internal static IDisposable BeginSlotScope(object slot) return new SlotScope(slotContext); } - internal static bool TryGetCurrentEndpointScopeState([NotNullWhen(true)] out EndpointLogScopeState? scopeState) + internal static bool TryGetCurrentEndpointScopeState([NotNullWhen(true)] out LogScopeState? scopeState) { if (currentSlot.Value is null) { @@ -104,14 +104,8 @@ internal static bool TryGetCurrentEndpointScopeState([NotNullWhen(true)] out End static SlotContext GetOrAddSlotContext(SlotKey slotKey) => slotContexts.GetOrAdd(slotKey, static key => new SlotContext(key.Value, CreateScopeState(key.Value))); - static EndpointLogScopeState CreateScopeState(object slot) => - slot is EndpointLogSlot endpointSlot - ? endpointSlot.ScopeState - : slot is EndpointReceiverLogSlot receiverSlot - ? receiverSlot.ScopeState - : slot is EndpointSatelliteLogSlot satelliteSlot - ? satelliteSlot.ScopeState - : new EndpointLogScopeState(slot, endpointIdentifier: null); + static LogScopeState CreateScopeState(object slot) => + slot is LogSlot logSlot ? logSlot.ScopeState : new EndpointLogScopeState(slot, endpointIdentifier: null); static bool TryGetSlotLoggerFactory(out SlotContext slotContext, out ILoggerFactory loggerFactory) { @@ -373,11 +367,11 @@ public SlotScope(SlotContext slot) readonly SlotContext? previousSlot; } - sealed class SlotContext(object identifier, EndpointLogScopeState scopeState) + sealed class SlotContext(object identifier, LogScopeState scopeState) { public object Identifier { get; } = identifier; public SlotKey Key { get; } = new(identifier); - public EndpointLogScopeState ScopeState { get; } = scopeState; + public LogScopeState ScopeState { get; } = scopeState; } readonly struct SlotKey(object value) : IEquatable From f712af1fc3da0c195e25fec6c3bb79ad010614aa Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Wed, 25 Feb 2026 14:46:59 +0100 Subject: [PATCH 12/12] Bind message session and inline wait until start --- ...ContainerMessageSessionIntegrationTests.cs | 52 +++++++++++ .../MessageSessionTests.cs | 32 +++++-- src/NServiceBus.Core/Endpoint.cs | 1 - src/NServiceBus.Core/EndpointCreator.cs | 4 + .../EndpointWithExternallyManagedContainer.cs | 4 + .../Hosting/ExternallyManagedContainerHost.cs | 25 +---- src/NServiceBus.Core/MessageSession.cs | 91 +++++++++++++++++-- src/NServiceBus.Core/StartableEndpoint.cs | 4 +- 8 files changed, 175 insertions(+), 38 deletions(-) create mode 100644 src/NServiceBus.Core.Tests/Hosting/ExternallyManagedContainerMessageSessionIntegrationTests.cs diff --git a/src/NServiceBus.Core.Tests/Hosting/ExternallyManagedContainerMessageSessionIntegrationTests.cs b/src/NServiceBus.Core.Tests/Hosting/ExternallyManagedContainerMessageSessionIntegrationTests.cs new file mode 100644 index 0000000000..5c8f268313 --- /dev/null +++ b/src/NServiceBus.Core.Tests/Hosting/ExternallyManagedContainerMessageSessionIntegrationTests.cs @@ -0,0 +1,52 @@ +#nullable enable + +namespace NServiceBus.Core.Tests.Host; + +using System; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +[TestFixture] +public class ExternallyManagedContainerMessageSessionIntegrationTests +{ + [Test] + public void Should_auto_register_deferred_message_session() + { + var services = new ServiceCollection(); + var endpointConfiguration = CreateEndpointConfiguration(); + + _ = EndpointWithExternallyManagedContainer.Create(endpointConfiguration, services); + + using var provider = services.BuildServiceProvider(); + var messageSession = provider.GetRequiredService(); + + Assert.That(messageSession, Is.Not.Null); + } + + [Test] + public void Deferred_message_session_should_honor_cancellation_before_start() + { + var services = new ServiceCollection(); + var endpointConfiguration = CreateEndpointConfiguration(); + + _ = EndpointWithExternallyManagedContainer.Create(endpointConfiguration, services); + + using var provider = services.BuildServiceProvider(); + var messageSession = provider.GetRequiredService(); + using var cts = new System.Threading.CancellationTokenSource(); + cts.Cancel(); + + Assert.ThrowsAsync(Is.InstanceOf(), async () => await messageSession.Send(new object(), new SendOptions(), cts.Token)); + } + + static EndpointConfiguration CreateEndpointConfiguration() + { + var endpointConfiguration = new EndpointConfiguration($"{nameof(ExternallyManagedContainerMessageSessionIntegrationTests)}-{Guid.NewGuid():N}"); + endpointConfiguration.UseSerialization(); + endpointConfiguration.UseTransport(new LearningTransport()); + + var scanner = endpointConfiguration.AssemblyScanner(); + scanner.Disable = true; + return endpointConfiguration; + } +} diff --git a/src/NServiceBus.Core.Tests/MessageSessionTests.cs b/src/NServiceBus.Core.Tests/MessageSessionTests.cs index 36bd728245..7be0f95718 100644 --- a/src/NServiceBus.Core.Tests/MessageSessionTests.cs +++ b/src/NServiceBus.Core.Tests/MessageSessionTests.cs @@ -1,4 +1,4 @@ -namespace NServiceBus.Core.Tests; +namespace NServiceBus.Core.Tests; using Pipeline; using System; @@ -32,8 +32,8 @@ public async Task Should_not_share_root_context_across_operations() } }; - var session = new MessageSession(new ThrowingServiceProvider(), messageOperations, new ThrowingPipelineCache(), - CancellationToken.None); + var session = new MessageSession(loggingSlot: new object()); + session.Initialize(new ThrowingServiceProvider(), messageOperations, new ThrowingPipelineCache(), CancellationToken.None); await session.Send(new object()); await session.Send(new object()); @@ -56,7 +56,8 @@ public void Should_propagate_endpoint_cancellation_status_to_context() } }; - var session = new MessageSession(new ThrowingServiceProvider(), messageOperations, new ThrowingPipelineCache(), new CancellationToken(true)); + var session = new MessageSession(loggingSlot: new object()); + session.Initialize(new ThrowingServiceProvider(), messageOperations, new ThrowingPipelineCache(), new CancellationToken(true)); Assert.ThrowsAsync(async () => await session.Send(new object(), CancellationToken.None)); @@ -76,9 +77,26 @@ public void Should_propagate_request_cancellation_status_to_context() } }; - var session = new MessageSession(new ThrowingServiceProvider(), messageOperations, new ThrowingPipelineCache(), CancellationToken.None); + var session = new MessageSession(loggingSlot: new object()); + session.Initialize(new ThrowingServiceProvider(), messageOperations, new ThrowingPipelineCache(), CancellationToken.None); - Assert.ThrowsAsync(async () => - await session.Send(new object(), new CancellationToken(true))); + Assert.ThrowsAsync(async () => await session.Send(new object(), new CancellationToken(true))); + } + + [Test] + public async Task Deferred_session_should_wait_until_initialized() + { + var deferredSession = new MessageSession(loggingSlot: new object()); + var messageOperations = new TestableMessageOperations(); + + var sendTask = deferredSession.Send(new object(), new SendOptions()); + + await Task.Delay(50); + Assert.That(sendTask.IsCompleted, Is.False); + + deferredSession.Initialize(new ThrowingServiceProvider(), messageOperations, new ThrowingPipelineCache(), CancellationToken.None); + await sendTask; + + Assert.That(messageOperations.SendPipeline.LastContext, Is.Not.Null); } } \ No newline at end of file diff --git a/src/NServiceBus.Core/Endpoint.cs b/src/NServiceBus.Core/Endpoint.cs index b2edae8723..160893df40 100644 --- a/src/NServiceBus.Core/Endpoint.cs +++ b/src/NServiceBus.Core/Endpoint.cs @@ -22,7 +22,6 @@ public static async Task Create(EndpointConfiguration config ArgumentNullException.ThrowIfNull(configuration); var serviceCollection = new ServiceCollection(); var endpointCreator = EndpointCreator.Create(configuration, serviceCollection); - var serviceProvider = serviceCollection.BuildServiceProvider(); var endpoint = endpointCreator.CreateStartableEndpoint(serviceProvider, serviceProviderIsExternallyManaged: false); diff --git a/src/NServiceBus.Core/EndpointCreator.cs b/src/NServiceBus.Core/EndpointCreator.cs index 95c4fdf6a0..fb17293b0a 100644 --- a/src/NServiceBus.Core/EndpointCreator.cs +++ b/src/NServiceBus.Core/EndpointCreator.cs @@ -158,6 +158,7 @@ void Configure() _ = hostingConfiguration.Services.AddMetrics(); hostingComponent = HostingComponent.Initialize(hostingConfiguration); + MessageSession = new MessageSession(hostingConfiguration.EndpointLogSlot); [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = TrimmingSuppressJustification)] static void DiscoverHandlers(ReceiveComponent.Settings receiveSettings, ICollection availableTypes) => receiveSettings.MessageHandlerRegistry.AddScannedHandlers(availableTypes); @@ -204,9 +205,12 @@ public StartableEndpoint CreateStartableEndpoint(IServiceProvider serviceProvide hostingComponent, sendComponent, serviceProvider, + MessageSession, serviceProviderIsExternallyManaged); } + internal MessageSession MessageSession { get; private set; } + PipelineComponent pipelineComponent; FeatureComponent featureComponent; ReceiveComponent receiveComponent; diff --git a/src/NServiceBus.Core/EndpointWithExternallyManagedContainer.cs b/src/NServiceBus.Core/EndpointWithExternallyManagedContainer.cs index b9e933ed9f..ae69bc78d9 100644 --- a/src/NServiceBus.Core/EndpointWithExternallyManagedContainer.cs +++ b/src/NServiceBus.Core/EndpointWithExternallyManagedContainer.cs @@ -2,6 +2,7 @@ using System; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; /// /// Provides factory methods for creating endpoints instances with an externally managed container. @@ -17,6 +18,9 @@ public static IStartableEndpointWithExternallyManagedContainer Create(EndpointCo ArgumentNullException.ThrowIfNull(serviceCollection); var endpointCreator = EndpointCreator.Create(configuration, serviceCollection); + + serviceCollection.TryAddSingleton(endpointCreator.MessageSession); + return new ExternallyManagedContainerHost(endpointCreator); } } \ No newline at end of file diff --git a/src/NServiceBus.Core/Hosting/ExternallyManagedContainerHost.cs b/src/NServiceBus.Core/Hosting/ExternallyManagedContainerHost.cs index b7acd6751a..82c6be75ef 100644 --- a/src/NServiceBus.Core/Hosting/ExternallyManagedContainerHost.cs +++ b/src/NServiceBus.Core/Hosting/ExternallyManagedContainerHost.cs @@ -11,23 +11,9 @@ public ExternallyManagedContainerHost(EndpointCreator endpointCreator) { this.endpointCreator = endpointCreator; - MessageSession = new Lazy(() => - { - if (messageSession == null) - { - throw new InvalidOperationException("The message session can only be used after the endpoint is started."); - } - return messageSession; - }); - - Builder = new Lazy(() => - { - if (objectBuilder == null) - { - throw new InvalidOperationException("The builder can only be used after the endpoint is started."); - } - return objectBuilder; - }); + MessageSession = new Lazy(() => !endpointCreator.MessageSession.Initialized ? throw new InvalidOperationException("The message session can only be used after the endpoint is started.") : endpointCreator.MessageSession); + + Builder = new Lazy(() => objectBuilder ?? throw new InvalidOperationException("The builder can only be used after the endpoint is started.")); } public Lazy MessageSession { get; } @@ -40,12 +26,9 @@ public async Task Start(IServiceProvider externalBuilder, Can var startableEndpoint = endpointCreator.CreateStartableEndpoint(externalBuilder, serviceProviderIsExternallyManaged: true); await startableEndpoint.RunInstallers(cancellationToken).ConfigureAwait(false); await startableEndpoint.Setup(cancellationToken).ConfigureAwait(false); - IEndpointInstance endpointInstance = await startableEndpoint.Start(cancellationToken).ConfigureAwait(false); - messageSession = endpointInstance; - return endpointInstance; + return await startableEndpoint.Start(cancellationToken).ConfigureAwait(false); } readonly EndpointCreator endpointCreator; - IMessageSession? messageSession; IServiceProvider? objectBuilder; } \ No newline at end of file diff --git a/src/NServiceBus.Core/MessageSession.cs b/src/NServiceBus.Core/MessageSession.cs index de370777b5..9eb9adea62 100644 --- a/src/NServiceBus.Core/MessageSession.cs +++ b/src/NServiceBus.Core/MessageSession.cs @@ -1,22 +1,76 @@ +#nullable enable + namespace NServiceBus; using System; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Logging; -class MessageSession( - IServiceProvider builder, - MessageOperations messageOperations, - IPipelineCache pipelineCache, - CancellationToken endpointStoppingToken) - : IMessageSession +class MessageSession : IMessageSession { - PipelineRootContext CreateContext(CancellationToken cancellationToken) => new(builder, messageOperations, pipelineCache, cancellationToken); + internal MessageSession(object loggingSlot) + { + this.loggingSlot = loggingSlot; + initializedTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + [MemberNotNullWhen(true, nameof(serviceProvider), nameof(messageOperations), nameof(pipelineCache), nameof(endpointStoppingToken))] + public bool Initialized => initializedTaskCompletionSource.Task.IsCompletedSuccessfully; + + [MemberNotNull(nameof(serviceProvider), nameof(messageOperations), nameof(pipelineCache), nameof(endpointStoppingToken))] + internal void Initialize( + IServiceProvider builder, + MessageOperations operations, + IPipelineCache cache, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(operations); + ArgumentNullException.ThrowIfNull(cache); + + if (Initialized) + { + return; + } + + serviceProvider = builder; + messageOperations = operations; + pipelineCache = cache; + endpointStoppingToken = cancellationToken; + initializedTaskCompletionSource.SetResult(); + } + + [MemberNotNull(nameof(serviceProvider), nameof(messageOperations), nameof(pipelineCache), nameof(endpointStoppingToken))] + async ValueTask WaitUntilInitialized(CancellationToken cancellationToken) + { + if (Initialized) + { + return; + } + +#pragma warning disable CS8774 // Member must have a non-null value when exiting. + await initializedTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } +#pragma warning restore CS8774 // Member must have a non-null value when exiting. + + PipelineRootContext CreateContext(CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(messageOperations); + ArgumentNullException.ThrowIfNull(pipelineCache); + + return new PipelineRootContext(serviceProvider, messageOperations, pipelineCache, cancellationToken); + } public async Task Send(object message, SendOptions sendOptions, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(message); ArgumentNullException.ThrowIfNull(sendOptions); + + using var _ = LogManager.BeginSlotScope(loggingSlot!); + await WaitUntilInitialized(cancellationToken).ConfigureAwait(false); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(endpointStoppingToken, cancellationToken); await messageOperations.Send(CreateContext(linkedTokenSource.Token), message, sendOptions).ConfigureAwait(false); } @@ -25,6 +79,9 @@ public async Task Send(Action messageConstructor, SendOptions sendOptions, { ArgumentNullException.ThrowIfNull(messageConstructor); ArgumentNullException.ThrowIfNull(sendOptions); + + using var _ = LogManager.BeginSlotScope(loggingSlot!); + await WaitUntilInitialized(cancellationToken).ConfigureAwait(false); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(endpointStoppingToken, cancellationToken); await messageOperations.Send(CreateContext(linkedTokenSource.Token), messageConstructor, sendOptions).ConfigureAwait(false); } @@ -33,6 +90,9 @@ public async Task Publish(object message, PublishOptions publishOptions, Cancell { ArgumentNullException.ThrowIfNull(message); ArgumentNullException.ThrowIfNull(publishOptions); + + using var _ = LogManager.BeginSlotScope(loggingSlot!); + await WaitUntilInitialized(cancellationToken).ConfigureAwait(false); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(endpointStoppingToken, cancellationToken); await messageOperations.Publish(CreateContext(linkedTokenSource.Token), message, publishOptions).ConfigureAwait(false); } @@ -41,6 +101,9 @@ public async Task Publish(Action messageConstructor, PublishOptions publis { ArgumentNullException.ThrowIfNull(messageConstructor); ArgumentNullException.ThrowIfNull(publishOptions); + + using var _ = LogManager.BeginSlotScope(loggingSlot!); + await WaitUntilInitialized(cancellationToken).ConfigureAwait(false); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(endpointStoppingToken, cancellationToken); await messageOperations.Publish(CreateContext(linkedTokenSource.Token), messageConstructor, publishOptions).ConfigureAwait(false); } @@ -49,12 +112,17 @@ public async Task Subscribe(Type eventType, SubscribeOptions subscribeOptions, C { ArgumentNullException.ThrowIfNull(eventType); ArgumentNullException.ThrowIfNull(subscribeOptions); + + using var _ = LogManager.BeginSlotScope(loggingSlot!); + await WaitUntilInitialized(cancellationToken).ConfigureAwait(false); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(endpointStoppingToken, cancellationToken); await messageOperations.Subscribe(CreateContext(linkedTokenSource.Token), eventType, subscribeOptions).ConfigureAwait(false); } public async Task SubscribeAll(Type[] eventTypes, SubscribeOptions subscribeOptions, CancellationToken cancellationToken = default) { + using var _ = LogManager.BeginSlotScope(loggingSlot!); + await WaitUntilInitialized(cancellationToken).ConfigureAwait(false); // set a flag on the context so that subscribe implementations know which send API was used. subscribeOptions.Context.Set(SubscribeAllFlagKey, true); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(endpointStoppingToken, cancellationToken); @@ -65,9 +133,18 @@ public async Task Unsubscribe(Type eventType, UnsubscribeOptions unsubscribeOpti { ArgumentNullException.ThrowIfNull(eventType); ArgumentNullException.ThrowIfNull(unsubscribeOptions); + + using var _ = LogManager.BeginSlotScope(loggingSlot!); + await WaitUntilInitialized(cancellationToken).ConfigureAwait(false); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(endpointStoppingToken, cancellationToken); await messageOperations.Unsubscribe(CreateContext(linkedTokenSource.Token), eventType, unsubscribeOptions).ConfigureAwait(false); } + readonly object? loggingSlot; + IServiceProvider? serviceProvider; + MessageOperations? messageOperations; + IPipelineCache? pipelineCache; + CancellationToken endpointStoppingToken; + readonly TaskCompletionSource initializedTaskCompletionSource; internal const string SubscribeAllFlagKey = "NServiceBus.SubscribeAllFlag"; } \ No newline at end of file diff --git a/src/NServiceBus.Core/StartableEndpoint.cs b/src/NServiceBus.Core/StartableEndpoint.cs index 6397c36658..187d90bb71 100644 --- a/src/NServiceBus.Core/StartableEndpoint.cs +++ b/src/NServiceBus.Core/StartableEndpoint.cs @@ -21,6 +21,7 @@ class StartableEndpoint( HostingComponent hostingComponent, SendComponent sendComponent, IServiceProvider serviceProvider, + MessageSession messageSession, bool serviceProviderIsExternallyManaged) { public async Task RunInstallers(CancellationToken cancellationToken = default) @@ -39,7 +40,7 @@ public async Task Setup(CancellationToken cancellationToken = default) var messageOperations = sendComponent.CreateMessageOperations(serviceProvider, pipelineComponent); stoppingTokenSource = new CancellationTokenSource(); - messageSession = new MessageSession(serviceProvider, messageOperations, pipelineCache, stoppingTokenSource.Token); + messageSession.Initialize(serviceProvider, messageOperations, pipelineCache, stoppingTokenSource.Token); var consecutiveFailuresConfig = settings.Get(); @@ -78,7 +79,6 @@ public async Task Start(CancellationToken cancellationToken = return runningInstance; } - MessageSession messageSession; TransportInfrastructure transportInfrastructure; CancellationTokenSource stoppingTokenSource; } \ No newline at end of file