From 45530a1828916db5acd56cc05422fd1c6bd4ec80 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Thu, 3 Sep 2026 08:38:46 +0200 Subject: [PATCH] Use generated saga correlation accessors and emit finder-only sagas Partial backport of cd2db0b (#7918) to release-10.2, complementing the #7922 generator fix. SagaMetadata.Create now forwards the generated correlation accessor to SagaMapper, so correlation ids load through the source-generated accessor instead of runtime expression compilation. The saga source generator parses and emits finder-only sagas with a null correlation accessor, and the AddSaga IL2026 suppressor only suppresses when an interceptor can actually be emitted. The AddMessage<>-related changes of the original commit are intentionally not backported. --- ...eneratorTests.FinderOnlySagas.approved.txt | 172 ++++++++++++++++++ .../Sagas/AddSagaGeneratorTests.cs | 55 ++++++ .../AddSagaInterceptorSuppressorTests.cs | 54 ++++++ .../Sagas/AddSagaInterceptor.Suppressor.cs | 17 +- .../Sagas/Sagas.Emitter.cs | 14 +- .../Sagas/Sagas.Parser.cs | 9 +- .../Sagas/SagaMetadataCreationTests.cs | 34 ++++ src/NServiceBus.Core/Sagas/SagaMetadata.cs | 2 +- 8 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 src/NServiceBus.Core.Analyzer.Tests.Roslyn5/ApprovalFiles/AddSagaGeneratorTests.FinderOnlySagas.approved.txt diff --git a/src/NServiceBus.Core.Analyzer.Tests.Roslyn5/ApprovalFiles/AddSagaGeneratorTests.FinderOnlySagas.approved.txt b/src/NServiceBus.Core.Analyzer.Tests.Roslyn5/ApprovalFiles/AddSagaGeneratorTests.FinderOnlySagas.approved.txt new file mode 100644 index 00000000000..58ad635b85d --- /dev/null +++ b/src/NServiceBus.Core.Analyzer.Tests.Roslyn5/ApprovalFiles/AddSagaGeneratorTests.FinderOnlySagas.approved.txt @@ -0,0 +1,172 @@ +// == NServiceBus.Core.Analyzer/NServiceBus.Core.Analyzer.Sagas.AddSagaGenerator/HandlerRegistrations.Sagas.g.cs ======= +// + +#nullable enable annotations +#nullable disable warnings + +// Suppress warnings about [Obsolete] member usage in generated code. +#pragma warning disable CS0612, CS0618 + +namespace NServiceBus +{ + public static partial class FinderOnlySagasHandlerRegistryExtensions + { + public sealed partial class FinderOnlySagasRootRegistry + { + public sealed partial class FinderOnlyRegistry + { + partial void AddAllSagasCore() + { + AddFinderOnlySaga(); + } + + /// + /// Registers the saga with the endpoint configuration. + /// + public void AddFinderOnlySaga() + { + var sagaMetadataCollection = NServiceBus.Configuration.AdvancedExtensibility.AdvancedExtensibilityExtensions.GetSettings(_configuration) + .GetOrCreate(); + var associatedMessages = new NServiceBus.Sagas.SagaMessage[] + { + new NServiceBus.Sagas.SagaMessage(typeof(global::FinderOnly.StartSagaMessage), true, false), + }; + NServiceBus.Sagas.MessagePropertyAccessor[] propertyAccessors = [ + ]; + var metadata = NServiceBus.Sagas.SagaMetadata.Create(associatedMessages, null, propertyAccessors); + sagaMetadataCollection.Add(metadata); + + var settings = NServiceBus.Configuration.AdvancedExtensibility.AdvancedExtensibilityExtensions.GetSettings(_configuration); + var messageHandlerRegistry = settings.GetOrCreate(); + var messageMetadataRegistry = settings.GetOrCreate(); + messageHandlerRegistry.AddMessageHandlerForMessage(); + messageMetadataRegistry.RegisterMessageTypeWithHierarchy(typeof(global::FinderOnly.StartSagaMessage), []); + } + } + } + } +} + +// == NServiceBus.Core.Analyzer/NServiceBus.Core.Analyzer.AddHandlerAndSagasRegistrationGenerator/HandlerRegistrations.g.cs == +// + +#nullable enable annotations +#nullable disable warnings + +// Suppress warnings about [Obsolete] member usage in generated code. +#pragma warning disable CS0612, CS0618 + +namespace NServiceBus +{ + /// + /// Provides access to handler and saga registries discovered in the FinderOnlySagas assembly. + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("NService.Core.Analyzer.Tests", "1.0.0")] + public static partial class FinderOnlySagasHandlerRegistryExtensions + { + extension (global::NServiceBus.HandlerRegistry registry) + { + /// + /// Gets the root registry for handler and saga types in the FinderOnlySagas assembly. + /// + /// + /// Use the returned registry to access namespace-specific registries and add-all methods for this assembly. + /// + public FinderOnlySagasRootRegistry FinderOnlySagasAssembly => new(registry.Configuration); + } + + /// + /// Root registry to add handlers and sagas for the entire assembly. + /// + public sealed partial class FinderOnlySagasRootRegistry(global::NServiceBus.EndpointConfiguration configuration) + { + readonly global::NServiceBus.EndpointConfiguration _configuration = configuration ?? throw new System.ArgumentNullException(nameof(configuration)); + + /// + /// Gets the registry for the FinderOnly namespace segment under this branch. + /// + public FinderOnlyRegistry FinderOnly => new(_configuration); + + /// + /// Registers all handlers and sagas for this namespace segment and its child namespaces. + /// + /// + /// Includes child registries: . + /// + public void AddAll() + { + AddAllHandlers(); + AddAllSagas(); + } + + /// + /// Registers all handlers for this namespace segment and its child namespaces. + /// + /// + /// Includes child registries: . + /// + public void AddAllHandlers() + { + AddAllHandlersCore(); + FinderOnly.AddAllHandlers(); + } + + /// + /// Registers all sagas for this namespace segment and its child namespaces. + /// + /// + /// Includes child registries: . + /// + public void AddAllSagas() + { + AddAllSagasCore(); + FinderOnly.AddAllSagas(); + } + + partial void AddAllHandlersCore(); + partial void AddAllSagasCore(); + + /// + /// Registry for the FinderOnly namespace segment. Use this registry to add handlers and sagas for this branch. + /// + public sealed partial class FinderOnlyRegistry(global::NServiceBus.EndpointConfiguration configuration) + { + readonly global::NServiceBus.EndpointConfiguration _configuration = configuration ?? throw new System.ArgumentNullException(nameof(configuration)); + + /// + /// Registers all handlers and sagas for this namespace segment and its child namespaces. + /// + /// + /// Includes sagas in this namespace: . + /// + public void AddAll() + { + AddAllHandlers(); + AddAllSagas(); + } + + /// + /// Registers all handlers for this namespace segment and its child namespaces. + /// + public void AddAllHandlers() + { + AddAllHandlersCore(); + } + + /// + /// Registers all sagas for this namespace segment and its child namespaces. + /// + /// + /// Includes sagas in this namespace: . + /// + public void AddAllSagas() + { + AddAllSagasCore(); + } + + partial void AddAllHandlersCore(); + partial void AddAllSagasCore(); + } + } + } +} \ No newline at end of file diff --git a/src/NServiceBus.Core.Analyzer.Tests.Roslyn5/Sagas/AddSagaGeneratorTests.cs b/src/NServiceBus.Core.Analyzer.Tests.Roslyn5/Sagas/AddSagaGeneratorTests.cs index 59beefb9597..29f01011ae3 100644 --- a/src/NServiceBus.Core.Analyzer.Tests.Roslyn5/Sagas/AddSagaGeneratorTests.cs +++ b/src/NServiceBus.Core.Analyzer.Tests.Roslyn5/Sagas/AddSagaGeneratorTests.cs @@ -7,6 +7,61 @@ [TestFixture] public class AddSagaGeneratorTests { + [Test] + public void FinderOnlySagas() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using NServiceBus; + using NServiceBus.Persistence; + using NServiceBus.Extensibility; + using NServiceBus.Sagas; + + public class Test + { + public void Configure(EndpointConfiguration cfg) + { + cfg.Handlers.FinderOnlySagasAssembly.AddAll(); + } + } + + namespace FinderOnly + { + [Saga] + public class FinderOnlySaga : Saga, + IAmStartedByMessages + { + protected override void ConfigureHowToFindSaga(SagaPropertyMapper mapper) + { + mapper.ConfigureFinderMapping(); + } + + public Task Handle(StartSagaMessage message, IMessageHandlerContext context) => Task.CompletedTask; + } + + public class FinderOnlySagaData : ContainSagaData + { + public string Property { get; set; } + } + + public class FinderOnlyFinder : ISagaFinder + { + public Task FindBy(StartSagaMessage message, ISynchronizedStorageSession storageSession, IReadOnlyContextBag context, CancellationToken cancellationToken = default) => Task.FromResult(default(FinderOnlySagaData)); + } + + public class StartSagaMessage : IMessage; + } + """; + + SourceGeneratorTest.ForIncrementalGenerator() + .WithIncrementalGenerator() + .WithSource(source, "test.cs") + .Run() + .Approve() + .AssertRunsAreEqual(); + } + [Test] public void BasicSagas() { diff --git a/src/NServiceBus.Core.Analyzer.Tests.Roslyn5/Sagas/AddSagaInterceptorSuppressorTests.cs b/src/NServiceBus.Core.Analyzer.Tests.Roslyn5/Sagas/AddSagaInterceptorSuppressorTests.cs index ef0f6d5a498..cbcc9fdfaab 100644 --- a/src/NServiceBus.Core.Analyzer.Tests.Roslyn5/Sagas/AddSagaInterceptorSuppressorTests.cs +++ b/src/NServiceBus.Core.Analyzer.Tests.Roslyn5/Sagas/AddSagaInterceptorSuppressorTests.cs @@ -57,6 +57,60 @@ public class SampleCommand : ICommand Assert.That(diagnostics, Does.Not.Contain("IL2026")); } + [Test] + public void SuppressesIL2026ForFinderOnlySaga() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using NServiceBus; + using NServiceBus.Persistence; + using NServiceBus.Extensibility; + using NServiceBus.Sagas; + + public class Test + { + public void Configure(EndpointConfiguration cfg) + { + cfg.AddSaga(); + } + } + + public class FinderOnlySaga : Saga, + IAmStartedByMessages + { + protected override void ConfigureHowToFindSaga(SagaPropertyMapper mapper) + { + mapper.ConfigureFinderMapping(); + } + + public Task Handle(StartSagaMessage message, IMessageHandlerContext context) => Task.CompletedTask; + } + + public class FinderOnlySagaData : ContainSagaData + { + public string Property { get; set; } + } + + public class FinderOnlyFinder : ISagaFinder + { + public Task FindBy(StartSagaMessage message, ISynchronizedStorageSession storageSession, IReadOnlyContextBag context, CancellationToken cancellationToken = default) => Task.FromResult(default(FinderOnlySagaData)); + } + + public class StartSagaMessage : IMessage; + """; + + var result = SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(source, "test.cs") + .WithAnalyzer() + .WithSuppressor() + .Run(); + + var diagnostics = result.GetCompilationOutput(); + + Assert.That(diagnostics, Does.Not.Contain("IL2026")); + } + [Test] public void DoesNotSuppressIL2026ForNonAddSagaCalls() { diff --git a/src/NServiceBus.Core.Analyzer/Sagas/AddSagaInterceptor.Suppressor.cs b/src/NServiceBus.Core.Analyzer/Sagas/AddSagaInterceptor.Suppressor.cs index 796b0b6ba37..edef54b7836 100644 --- a/src/NServiceBus.Core.Analyzer/Sagas/AddSagaInterceptor.Suppressor.cs +++ b/src/NServiceBus.Core.Analyzer/Sagas/AddSagaInterceptor.Suppressor.cs @@ -1,6 +1,7 @@ namespace NServiceBus.Core.Analyzer.Sagas; using System.Collections.Immutable; +using NServiceBus.Core.Analyzer.Handlers; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; @@ -45,10 +46,22 @@ public override void ReportSuppressions(SuppressionAnalysisContext context) var semanticModel = context.GetSemanticModel(sourceTree); var operation = semanticModel.GetOperation(node, context.CancellationToken); - if (operation is IInvocationOperation { TargetMethod: { } methodSymbol } && AddSagaInterceptor.Parser.IsAddSagaMethod(methodSymbol)) + if (operation is not IInvocationOperation { TargetMethod: { } methodSymbol } || !AddSagaInterceptor.Parser.IsAddSagaMethod(methodSymbol)) { - context.ReportSuppression(Suppression.Create(SuppressRUCDiagnostic, diagnostic)); + continue; + } + + // Only suppress when an interceptor can actually be emitted for this call site. A saga that cannot be + // parsed (no Saga base, abstract, or otherwise unsupported) keeps the RequiresUnreferencedCode + // fallback warning. + if (methodSymbol.TypeArguments[0] is not INamedTypeSymbol sagaType || + !HandlerKnownTypes.TryGet(context.Compilation, out var knownTypes) || + Sagas.Parser.Parse(semanticModel, sagaType, knownTypes, context.CancellationToken) is null) + { + continue; } + + context.ReportSuppression(Suppression.Create(SuppressRUCDiagnostic, diagnostic)); } } diff --git a/src/NServiceBus.Core.Analyzer/Sagas/Sagas.Emitter.cs b/src/NServiceBus.Core.Analyzer/Sagas/Sagas.Emitter.cs index d607c33b67e..6330a9dd6c7 100644 --- a/src/NServiceBus.Core.Analyzer/Sagas/Sagas.Emitter.cs +++ b/src/NServiceBus.Core.Analyzer/Sagas/Sagas.Emitter.cs @@ -48,11 +48,13 @@ static void EmitSagaMetadataAdd(SourceWriter sourceWriter, SagaSpec details) sourceWriter.WriteLine($"{propertyAccessorClassName}.Instance,"); } - var correlationPropertyAccessorClassName = CorrelationPropertyAccessorName(details.SagaDataFullyQualifiedName, details.CorrelationPropertyMapping); - var correlationPropertyAccessor = $"{correlationPropertyAccessorClassName}.Instance"; - sourceWriter.Indentation--; sourceWriter.WriteLine("];"); + + // Finder-only sagas have no correlation property and therefore no generated correlation accessor. + var correlationPropertyAccessor = details.CorrelationPropertyMapping is { } correlationProperty + ? $"{CorrelationPropertyAccessorName(details.SagaDataFullyQualifiedName, correlationProperty)}.Instance" + : "null"; sourceWriter.WriteLine($"var metadata = NServiceBus.Sagas.SagaMetadata.Create<{details.FullyQualifiedName}, {details.SagaDataFullyQualifiedName}>(associatedMessages, {correlationPropertyAccessor}, propertyAccessors);"); sourceWriter.WriteLine("sagaMetadataCollection.Add(metadata);"); } @@ -137,7 +139,11 @@ static void EmitCorrelationPropertyAccessors(SourceWriter sourceWriter, Immutabl var uniqueMappings = new Dictionary<(string SagaDataType, string PropertyType, string PropertyName), (CorrelationPropertyMappingSpec Mapping, string SagaDataType)>(); foreach (var saga in sagas) { - var mapping = saga.CorrelationPropertyMapping; + if (saga.CorrelationPropertyMapping is not { } mapping) + { + continue; + } + var key = (saga.SagaDataFullyQualifiedName, mapping.PropertyType, mapping.PropertyName); if (!uniqueMappings.ContainsKey(key)) { diff --git a/src/NServiceBus.Core.Analyzer/Sagas/Sagas.Parser.cs b/src/NServiceBus.Core.Analyzer/Sagas/Sagas.Parser.cs index 991f26a0172..b959dc3babb 100644 --- a/src/NServiceBus.Core.Analyzer/Sagas/Sagas.Parser.cs +++ b/src/NServiceBus.Core.Analyzer/Sagas/Sagas.Parser.cs @@ -18,7 +18,7 @@ public static partial class Sagas public record SagaSpec : AddHandlerAndSagasRegistrationGenerator.Parser.BaseSpec { - public SagaSpec(HandlerSpec handler, string sagaDataFullyQualifiedName, CorrelationPropertyMappingSpec correlationProperty, ImmutableEquatableArray propertyMappings) + public SagaSpec(HandlerSpec handler, string sagaDataFullyQualifiedName, CorrelationPropertyMappingSpec? correlationProperty, ImmutableEquatableArray propertyMappings) : base(handler) { SagaDataFullyQualifiedName = sagaDataFullyQualifiedName; @@ -29,7 +29,7 @@ public SagaSpec(HandlerSpec handler, string sagaDataFullyQualifiedName, Correlat public string SagaDataFullyQualifiedName { get; } - public CorrelationPropertyMappingSpec CorrelationPropertyMapping { get; } + public CorrelationPropertyMappingSpec? CorrelationPropertyMapping { get; } public ImmutableEquatableArray PropertyMappings { get; } public HandlerSpec Handler { get; } } @@ -60,10 +60,11 @@ public static class Parser var sagaBaseSpec = Handlers.Parser.Parse(sagaType, BaseParser.SpecKind.Saga, knownTypes, cancellationToken); var sagaDataFullyQualifiedName = sagaDataType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - // Analyze ConfigureHowToFindSaga to extract mappings + // Analyze ConfigureHowToFindSaga to extract mappings. Finder-only sagas have no correlation property + // and no property mappings but are still valid sagas that must be registered. var (correlationProperty, propertyMappings) = ExtractPropertyMappings(sagaType, sagaSemanticModel, cancellationToken); - return correlationProperty is null ? null : new SagaSpec(sagaBaseSpec, sagaDataFullyQualifiedName, correlationProperty.Value, propertyMappings); + return new SagaSpec(sagaBaseSpec, sagaDataFullyQualifiedName, correlationProperty, propertyMappings); } static INamedTypeSymbol? GetSagaDataType(INamedTypeSymbol sagaType) diff --git a/src/NServiceBus.Core.Tests/Sagas/SagaMetadataCreationTests.cs b/src/NServiceBus.Core.Tests/Sagas/SagaMetadataCreationTests.cs index 426dd5b6067..7e14e7e495e 100644 --- a/src/NServiceBus.Core.Tests/Sagas/SagaMetadataCreationTests.cs +++ b/src/NServiceBus.Core.Tests/Sagas/SagaMetadataCreationTests.cs @@ -78,6 +78,40 @@ public void HandleBothUniqueAttributeAndMapping() } } + [Test] + public void When_generated_correlation_accessor_is_supplied_it_is_used() + { + var accessor = new TestCorrelationPropertyAccessor(); + var metadata = SagaMetadata.Create( + [new SagaMessage(typeof(SomeMessage), true, false)], + accessor, + []); + + Assert.That(metadata.TryGetCorrelationProperty(out var correlatedProperty), Is.True); + Assert.That(correlatedProperty.Accessor, Is.SameAs(accessor)); + } + + [Test] + public void When_no_correlation_accessor_is_supplied_an_expression_based_accessor_is_created() + { + var metadata = SagaMetadata.Create( + [new SagaMessage(typeof(SomeMessage), true, false)], + null, + []); + + Assert.That(metadata.TryGetCorrelationProperty(out var correlatedProperty), Is.True); + Assert.That(correlatedProperty.Accessor, Is.TypeOf>()); + } + + class TestCorrelationPropertyAccessor : CorrelationPropertyAccessor + { + public override void WriteTo(IContainSagaData sagaData, object value) + { + } + + public override object AccessFrom(IContainSagaData sagaData) => null; + } + [Test] public void AutomaticallyAddUniqueForMappedProperty() { diff --git a/src/NServiceBus.Core/Sagas/SagaMetadata.cs b/src/NServiceBus.Core/Sagas/SagaMetadata.cs index 2fdacc9db6c..35f32a1bc60 100644 --- a/src/NServiceBus.Core/Sagas/SagaMetadata.cs +++ b/src/NServiceBus.Core/Sagas/SagaMetadata.cs @@ -143,7 +143,7 @@ public static IEnumerable CreateMany(IEnumerable sagaTypes) var saga = (Saga)RuntimeHelpers.GetUninitializedObject(sagaType); - var mapper = new SagaMapper(sagaType, associatedMessages, propertyAccessors ?? []); + var mapper = new SagaMapper(sagaType, associatedMessages, propertyAccessors ?? [], correlationPropertyAccessor); saga.ConfigureHowToFindSaga(mapper);