From 0975e2b4314759cb95ee66e7f6c582cc6f90810b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Thu, 19 Mar 2026 13:35:18 +0100 Subject: [PATCH 01/68] Add failing test --- .../DiagnosticIds.cs | 23 ++++++++++----- .../FunctionEndpointGeneratorTests.cs | 28 ++++++++++++++++--- .../MissingCompositionCallAnalyzerTests.cs | 6 ++-- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs index 8f53d6b..95ccbdc 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs @@ -2,15 +2,16 @@ namespace NServiceBus.AzureFunctions.Analyzer; using Microsoft.CodeAnalysis; -static class DiagnosticIds +public static class DiagnosticIds { public const string ClassMustBePartial = "NSBFUNC001"; public const string ShouldNotImplementIHandleMessages = "NSBFUNC002"; public const string MethodMustBePartial = "NSBFUNC003"; - public const string MultipleConfigureMethods = "NSBFUNC005"; public const string MissingAddNServiceBusFunctionsCall = "NSBFUNC004"; + public const string MultipleConfigureMethods = "NSBFUNC005"; + public const string MissingAutoComplete = "NSBFUNC006"; - public static readonly DiagnosticDescriptor ClassMustBePartialDescriptor = new( + internal static readonly DiagnosticDescriptor ClassMustBePartialDescriptor = new( id: ClassMustBePartial, title: "Class containing [NServiceBusFunction] must be partial", messageFormat: "Class '{0}' must be declared as partial to use [NServiceBusFunction]", @@ -18,7 +19,7 @@ static class DiagnosticIds defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); - public static readonly DiagnosticDescriptor ShouldNotImplementIHandleMessagesDescriptor = new( + internal static readonly DiagnosticDescriptor ShouldNotImplementIHandleMessagesDescriptor = new( id: ShouldNotImplementIHandleMessages, title: "Function class should not implement IHandleMessages", messageFormat: "Class '{0}' should not implement IHandleMessages; message handlers should be registered separately via IEndpointConfiguration", @@ -26,7 +27,7 @@ static class DiagnosticIds defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); - public static readonly DiagnosticDescriptor MethodMustBePartialDescriptor = new( + internal static readonly DiagnosticDescriptor MethodMustBePartialDescriptor = new( id: MethodMustBePartial, title: "Method with [NServiceBusFunction] must be partial", messageFormat: "Method '{0}' must be declared as partial to use [NServiceBusFunction]", @@ -34,7 +35,7 @@ static class DiagnosticIds defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); - public static readonly DiagnosticDescriptor MultipleConfigureMethodsDescriptor = new( + internal static readonly DiagnosticDescriptor MultipleConfigureMethodsDescriptor = new( id: MultipleConfigureMethods, title: "Multiple configuration methods found", messageFormat: "Multiple '{0}' configuration methods found on class '{1}'", @@ -42,7 +43,15 @@ static class DiagnosticIds defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); - public static readonly DiagnosticDescriptor MissingAddNServiceBusFunctionsCallDescriptor = new( + internal static readonly DiagnosticDescriptor AutoCompleteMustBeSet = new( + id: MissingAutoComplete, + title: "Autocomplete property not set on service bus trigger", + messageFormat: "The auto complete property must be explicitly set to false on service bus triggers", + category: "NServiceBus.AzureFunctions", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor MissingAddNServiceBusFunctionsCallDescriptor = new( id: MissingAddNServiceBusFunctionsCall, title: "AddNServiceBusFunctions() is not called", messageFormat: "This project has NServiceBus endpoint registrations but does not call builder.AddNServiceBusFunctions(). Endpoints will not be started.", diff --git a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs index 6177657..55a12cc 100644 --- a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs +++ b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs @@ -15,10 +15,11 @@ public void GeneratesFunctionEndpoint() => .SuppressCompilationErrors() .Approve(); - [TestCase(FunctionClassMustBePartial, "NSBFUNC001")] - [TestCase(FunctionClassShouldNotImplementIHandleMessages, "NSBFUNC002")] - [TestCase(FunctionMethodMustBePartial, "NSBFUNC003")] - [TestCase(MultipleConfigureMethods, "NSBFUNC005")] + [TestCase(FunctionClassMustBePartial, DiagnosticIds.ClassMustBePartial)] + [TestCase(FunctionClassShouldNotImplementIHandleMessages, DiagnosticIds.ShouldNotImplementIHandleMessages)] + [TestCase(FunctionMethodMustBePartial, DiagnosticIds.MethodMustBePartial)] + [TestCase(MultipleConfigureMethods, DiagnosticIds.MultipleConfigureMethods)] + [TestCase(MissingAutoComplete, DiagnosticIds.MissingAutoComplete)] public void ReportsGeneratorDiagnostics(string source, string diagnosticId) { var result = SourceGeneratorTest.ForIncrementalGenerator() @@ -129,4 +130,23 @@ public static void ConfigureProcessOrder( } } """; + + const string MissingAutoComplete = """ + namespace Demo; + + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [ServiceBusTrigger("sales-queue", Connection = "AzureServiceBus")] ServiceBusReceivedMessage message, + ServiceBusMessageActions messageActions, + FunctionContext context, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder(EndpointConfiguration endpointConfiguration) + { + } + } + """; } \ No newline at end of file diff --git a/src/Tests.Analyzers/MissingCompositionCallAnalyzerTests.cs b/src/Tests.Analyzers/MissingCompositionCallAnalyzerTests.cs index d67aab7..52150e4 100644 --- a/src/Tests.Analyzers/MissingCompositionCallAnalyzerTests.cs +++ b/src/Tests.Analyzers/MissingCompositionCallAnalyzerTests.cs @@ -16,7 +16,7 @@ public void ReportsDiagnosticWhenCompositionCallIsMissing() .Run(); var diagnostics = result.GetAnalyzerDiagnostics(); - Assert.That(diagnostics, Has.Some.Matches(d => d.Id == "NSBFUNC004")); + Assert.That(diagnostics, Has.Some.Matches(d => d.Id == DiagnosticIds.MissingAddNServiceBusFunctionsCall)); } [Test] @@ -48,7 +48,7 @@ public static void AddNServiceBusFunctions(this Builder builder) .Run(); var diagnostics = result.GetAnalyzerDiagnostics(); - Assert.That(diagnostics, Has.Some.Matches(d => d.Id == "NSBFUNC004")); + Assert.That(diagnostics, Has.Some.Matches(d => d.Id == DiagnosticIds.MissingAddNServiceBusFunctionsCall)); } [Test] @@ -75,7 +75,7 @@ public static void Configure(FunctionsApplicationBuilder builder) .Run(); var diagnostics = result.GetAnalyzerDiagnostics(); - Assert.That(diagnostics, Has.None.Matches(d => d.Id == "NSBFUNC004")); + Assert.That(diagnostics, Has.None.Matches(d => d.Id == DiagnosticIds.MissingAddNServiceBusFunctionsCall)); } static SourceGeneratorTest CreateSourceGeneratorAnalyzerTest() => From 44493f0b758fb396e0ea5788948794e6f2e7089a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Thu, 19 Mar 2026 14:31:59 +0100 Subject: [PATCH 02/68] Add diagnostics for incorrect auto complete --- .../BillingFunctions.cs | 4 ++-- src/IntegrationTest.Sales/SalesEndpoint.cs | 2 +- .../ShippingEndpoint.cs | 2 +- .../DiagnosticIds.cs | 10 ++++----- .../FunctionEndpointGenerator.Parser.cs | 12 ++++++++++ ...s.GeneratesProjectComposition.approved.txt | 2 +- .../FunctionEndpointGeneratorTests.cs | 22 ++++++++++++++++++- src/Tests.Analyzers/TestSources.cs | 2 +- 8 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/IntegrationTest.Billing/BillingFunctions.cs b/src/IntegrationTest.Billing/BillingFunctions.cs index bbfe364..8d8fb0c 100644 --- a/src/IntegrationTest.Billing/BillingFunctions.cs +++ b/src/IntegrationTest.Billing/BillingFunctions.cs @@ -12,7 +12,7 @@ public partial class BillingFunctions [Function("BillingApi")] [NServiceBusFunction] public partial Task BillingApi( - [ServiceBusTrigger("billing-api", Connection = "AzureWebJobsServiceBus", AutoCompleteMessages = true)] + [ServiceBusTrigger("billing-api", Connection = "AzureWebJobsServiceBus", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, FunctionContext functionContext, @@ -27,7 +27,7 @@ public static void ConfigureBillingApi(EndpointConfiguration configuration) [Function("BillingBackend")] [NServiceBusFunction] public partial Task BillingBackend( - [ServiceBusTrigger("billing-backend", Connection = "AzureWebJobsServiceBus", AutoCompleteMessages = true)] + [ServiceBusTrigger("billing-backend", Connection = "AzureWebJobsServiceBus", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, FunctionContext functionContext, diff --git a/src/IntegrationTest.Sales/SalesEndpoint.cs b/src/IntegrationTest.Sales/SalesEndpoint.cs index dc9dbf2..a50ab47 100644 --- a/src/IntegrationTest.Sales/SalesEndpoint.cs +++ b/src/IntegrationTest.Sales/SalesEndpoint.cs @@ -11,7 +11,7 @@ public partial class SalesEndpoint { [Function("Sales")] public partial Task Sales( - [ServiceBusTrigger("sales", Connection = "AzureWebJobsServiceBus", AutoCompleteMessages = true)] + [ServiceBusTrigger("sales", Connection = "AzureWebJobsServiceBus", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, FunctionContext functionContext, diff --git a/src/IntegrationTest.Shipping/ShippingEndpoint.cs b/src/IntegrationTest.Shipping/ShippingEndpoint.cs index 1fecd10..90661c5 100644 --- a/src/IntegrationTest.Shipping/ShippingEndpoint.cs +++ b/src/IntegrationTest.Shipping/ShippingEndpoint.cs @@ -9,7 +9,7 @@ public partial class ShippingEndpoint { [Function(nameof(Shipping))] public partial Task Shipping( - [ServiceBusTrigger("shipping", AutoCompleteMessages = true)] + [ServiceBusTrigger("shipping", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, FunctionContext functionContext, diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs index 95ccbdc..3a07bbf 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs @@ -9,7 +9,7 @@ public static class DiagnosticIds public const string MethodMustBePartial = "NSBFUNC003"; public const string MissingAddNServiceBusFunctionsCall = "NSBFUNC004"; public const string MultipleConfigureMethods = "NSBFUNC005"; - public const string MissingAutoComplete = "NSBFUNC006"; + public const string AutoCompleteEnabled = "NSBFUNC006"; internal static readonly DiagnosticDescriptor ClassMustBePartialDescriptor = new( id: ClassMustBePartial, @@ -43,10 +43,10 @@ public static class DiagnosticIds defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); - internal static readonly DiagnosticDescriptor AutoCompleteMustBeSet = new( - id: MissingAutoComplete, - title: "Autocomplete property not set on service bus trigger", - messageFormat: "The auto complete property must be explicitly set to false on service bus triggers", + internal static readonly DiagnosticDescriptor AutoCompleteMustBeExplicitlyDisabled = new( + id: AutoCompleteEnabled, + title: "Autocomplete property must be disabled", + messageFormat: "The auto complete property on the service bus trigger for method '{0}' must be explicitly set to false", category: "NServiceBus.AzureFunctions", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs index a99dbb3..a1caf6f 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs @@ -125,12 +125,24 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo queueName = pAttr.ConstructorArguments[0].Value as string; } + var autoCompleteEnabled = true; foreach (var namedArg in pAttr.NamedArguments) { if (namedArg.Key == "Connection") { connectionName = namedArg.Value.Value as string; } + + if (namedArg.Key == "AutoCompleteMessages") + { + var autoComplete = namedArg.Value.Value as bool?; + autoCompleteEnabled = autoComplete!.Value; + } + } + + if (autoCompleteEnabled) + { + diagnostics.Add(CreateDiagnostic(DiagnosticIds.AutoCompleteMustBeExplicitlyDisabled, method, method.Name)); } messageParamName = param.Name; diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt index 9af5f28..fd15951 100644 --- a/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt @@ -15,7 +15,7 @@ public partial class Functions [NServiceBusFunction] [Function("ProcessOrder")] public partial Task Run( - [ServiceBusTrigger("sales-queue", Connection = "AzureServiceBus")] ServiceBusReceivedMessage message, + [ServiceBusTrigger("sales-queue", Connection = "AzureServiceBus" , AutoCompleteMessages = false)] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, FunctionContext context, CancellationToken cancellationToken); diff --git a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs index 55a12cc..aa9a1b1 100644 --- a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs +++ b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs @@ -19,7 +19,8 @@ public void GeneratesFunctionEndpoint() => [TestCase(FunctionClassShouldNotImplementIHandleMessages, DiagnosticIds.ShouldNotImplementIHandleMessages)] [TestCase(FunctionMethodMustBePartial, DiagnosticIds.MethodMustBePartial)] [TestCase(MultipleConfigureMethods, DiagnosticIds.MultipleConfigureMethods)] - [TestCase(MissingAutoComplete, DiagnosticIds.MissingAutoComplete)] + [TestCase(MissingAutoComplete, DiagnosticIds.AutoCompleteEnabled)] + [TestCase(AutoCompleteEnabled, DiagnosticIds.AutoCompleteEnabled)] public void ReportsGeneratorDiagnostics(string source, string diagnosticId) { var result = SourceGeneratorTest.ForIncrementalGenerator() @@ -149,4 +150,23 @@ public static void ConfigureProcessOrder(EndpointConfiguration endpointConfigura } } """; + + const string AutoCompleteEnabled = """ + namespace Demo; + + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [ServiceBusTrigger("sales-queue", AutoCompleteMessages = true)] ServiceBusReceivedMessage message, + ServiceBusMessageActions messageActions, + FunctionContext context, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder(EndpointConfiguration endpointConfiguration) + { + } + } + """; } \ No newline at end of file diff --git a/src/Tests.Analyzers/TestSources.cs b/src/Tests.Analyzers/TestSources.cs index 21fe3e4..64ad223 100644 --- a/src/Tests.Analyzers/TestSources.cs +++ b/src/Tests.Analyzers/TestSources.cs @@ -10,7 +10,7 @@ public partial class Functions [NServiceBusFunction] [Function("ProcessOrder")] public partial Task Run( - [ServiceBusTrigger("sales-queue", Connection = "AzureServiceBus")] ServiceBusReceivedMessage message, + [ServiceBusTrigger("sales-queue", Connection = "AzureServiceBus" , AutoCompleteMessages = false)] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, FunctionContext context, CancellationToken cancellationToken); From bded1beee5a81ec5a0cc8b5ab7fff70cd1b4c6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Thu, 19 Mar 2026 14:40:24 +0100 Subject: [PATCH 03/68] Use explicit settlement --- .../AzureServiceBusMessageProcessor.cs | 3 +-- .../PipelineInvokingMessageProcessor.cs | 8 +++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusMessageProcessor.cs index 468077a..b83d154 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusMessageProcessor.cs @@ -7,7 +7,6 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus; public class AzureServiceBusMessageProcessor(AzureServiceBusServerlessTransport transport, string endpointName) { - //NOTE: Message actions and function context is here to be ready for future features like native dlq support without having to change the end user api. public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, FunctionContext functionContext, CancellationToken cancellationToken = default) { if (transport.MessageProcessor is null) @@ -16,6 +15,6 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc throw new InvalidOperationException($"Endpoint {endpointName} cannot process messages because it is configured in send-only mode."); } - await transport.MessageProcessor.Process(message, cancellationToken).ConfigureAwait(false); + await transport.MessageProcessor.Process(message, messageActions, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index bfe50ec..8996332 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -6,6 +6,7 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus.Serverless.TransportWrapper using System.Threading.Tasks; using System.Xml; using Azure.Messaging.ServiceBus; +using Microsoft.Azure.Functions.Worker; using NServiceBus.Extensibility; using NServiceBus.Transport; using NServiceBus.Transport.AzureServiceBus; @@ -23,7 +24,7 @@ public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnE cancellationToken) ?? Task.CompletedTask; } - public async Task Process(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) + public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, CancellationToken cancellationToken = default) { var messageId = message.MessageId ?? Guid.NewGuid().ToString("N"); var body = GetBody(message); @@ -39,10 +40,11 @@ public async Task Process(ServiceBusReceivedMessage message, CancellationToken c await onMessage!(messageContext, cancellationToken).ConfigureAwait(false); azureServiceBusTransportTransaction.Commit(); + await messageActions.CompleteMessageAsync(message, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - throw; + await messageActions.AbandonMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false); } catch (Exception exception) { @@ -57,7 +59,7 @@ public async Task Process(ServiceBusReceivedMessage message, CancellationToken c return; } - throw; + await messageActions.AbandonMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false); } } From cb5bc413ef3be7a8a398fb0afe7844d915c5f176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Thu, 19 Mar 2026 14:46:27 +0100 Subject: [PATCH 04/68] Wording --- src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs index 3a07bbf..814e42e 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs @@ -45,7 +45,7 @@ public static class DiagnosticIds internal static readonly DiagnosticDescriptor AutoCompleteMustBeExplicitlyDisabled = new( id: AutoCompleteEnabled, - title: "Autocomplete property must be disabled", + title: "Message auto completion must be explicitly disabled", messageFormat: "The auto complete property on the service bus trigger for method '{0}' must be explicitly set to false", category: "NServiceBus.AzureFunctions", defaultSeverity: DiagnosticSeverity.Error, From e6c0b1759f4078f4f6593e62e9c7736ce5f907ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Fri, 20 Mar 2026 14:40:09 +0100 Subject: [PATCH 05/68] Add first round of test --- src/IntegrationTest/Program.cs | 10 +- .../PipelineInvokingMessageProcessor.cs | 4 + src/Tests/MessageProcessorTests.cs | 109 ++++++++++++++++++ 3 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 src/Tests/MessageProcessorTests.cs diff --git a/src/IntegrationTest/Program.cs b/src/IntegrationTest/Program.cs index 6f1cab2..b91013a 100644 --- a/src/IntegrationTest/Program.cs +++ b/src/IntegrationTest/Program.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using IntegrationTest; using IntegrationTest.Shared; using IntegrationTest.Shared.Infrastructure; @@ -10,13 +9,8 @@ var builder = FunctionsApplication.CreateBuilder(args); builder.UseWhen(_ => true); -builder.Logging.ClearProviders(); -builder.Logging.AddJsonConsole(o => -{ - o.IncludeScopes = true; - o.JsonWriterOptions = new JsonWriterOptions { Indented = true }; -}); -builder.Logging.SetMinimumLevel(LogLevel.Information); +builder.Logging.AddSimpleConsole(); +builder.Logging.SetMinimumLevel(LogLevel.Warning); builder.Services.AddSingleton(); builder.Services.AddSingleton(new MyComponent("global")); diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 8996332..771dffa 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -28,7 +28,10 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { var messageId = message.MessageId ?? Guid.NewGuid().ToString("N"); var body = GetBody(message); + //TODO: Should we get the headers up here as well + var contextBag = new ContextBag(); + // Azure Service Bus transport also makes the incoming message available. We can do the same narrow the gap contextBag.Set(message); @@ -56,6 +59,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc if (errorHandleResult == ErrorHandleResult.Handled) { azureServiceBusTransportTransaction.Commit(); + await messageActions.CompleteMessageAsync(message, cancellationToken).ConfigureAwait(false); return; } diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs new file mode 100644 index 0000000..3d8f021 --- /dev/null +++ b/src/Tests/MessageProcessorTests.cs @@ -0,0 +1,109 @@ +namespace NServiceBus.AzureFunctions.Tests; + +using Azure.Messaging.ServiceBus; +using AzureServiceBus.Serverless.TransportWrapper; +using Microsoft.Azure.Functions.Worker; +using NUnit.Framework; +using Transport; + +[TestFixture] +public class MessageProcessorTests +{ + [Test] + public async Task ShouldCallCompleteWhenOnMessageSucceeds() + { + var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver()); + + var onErrorWasCalled = false; + var onMessageWasCalled = false; + await processor.Initialize(PushRuntimeSettings.Default, + (_, _) => + { + onMessageWasCalled = true; + return Task.CompletedTask; + }, (_, _) => + { + onErrorWasCalled = true; + return Task.FromResult(ErrorHandleResult.Handled); + }); + + var serviceBusReceivedMessage = ServiceBusModelFactory.ServiceBusReceivedMessage(); + var messageActions = new TestableMessageActions(); + + await processor.Process(serviceBusReceivedMessage, messageActions); + + using (Assert.EnterMultipleScope()) + { + Assert.IsTrue(onMessageWasCalled); + Assert.IsFalse(onErrorWasCalled); + Assert.IsTrue(messageActions.WasCompleted); + Assert.IsFalse(messageActions.WasAbandoned); + } + } + + [Test] + public async Task ShouldCallAbandonWhenOnMessageFailsAndRetryIsRequested() + { + var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver()); + + var onErrorWasCalled = false; + var onMessageWasCalled = false; + await processor.Initialize(PushRuntimeSettings.Default, + (_, _) => + { + onMessageWasCalled = true; + throw new Exception("simulated exception"); + }, (_, _) => + { + onErrorWasCalled = true; + return Task.FromResult(ErrorHandleResult.RetryRequired); + }); + + var serviceBusReceivedMessage = ServiceBusModelFactory.ServiceBusReceivedMessage(); + var messageActions = new TestableMessageActions(); + + await processor.Process(serviceBusReceivedMessage, messageActions); + + using (Assert.EnterMultipleScope()) + { + Assert.IsTrue(onMessageWasCalled); + Assert.IsTrue(onErrorWasCalled); + Assert.IsFalse(messageActions.WasCompleted); + Assert.IsTrue(messageActions.WasAbandoned); + } + } + + class TestableMessageActions : ServiceBusMessageActions + { + public bool WasCompleted { get; private set; } + + public bool WasAbandoned { get; private set; } + + public override Task CompleteMessageAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = new CancellationToken()) + { + WasCompleted = true; + return Task.CompletedTask; + } + + public override Task AbandonMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = null, CancellationToken cancellationToken = new CancellationToken()) + { + WasAbandoned = true; + return Task.CompletedTask; + } + } + + class FakeBaseReceiver : IMessageReceiver + { + public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnError onError, CancellationToken cancellationToken = new CancellationToken()) => Task.CompletedTask; + + public Task StartReceive(CancellationToken cancellationToken = new CancellationToken()) => throw new NotImplementedException(); + + public Task ChangeConcurrency(PushRuntimeSettings limitations, CancellationToken cancellationToken = new CancellationToken()) => throw new NotImplementedException(); + + public Task StopReceive(CancellationToken cancellationToken = new CancellationToken()) => throw new NotImplementedException(); + + public ISubscriptionManager Subscriptions { get; } + public string Id { get; } + public string ReceiveAddress { get; } = "TestEndpoint"; + } +} \ No newline at end of file From 39875bff51557609028191ef437b45f0e5da2b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 06:36:00 +0100 Subject: [PATCH 06/68] Consolidate test code --- .../PipelineInvokingMessageProcessor.cs | 2 + src/Tests/MessageProcessorTests.cs | 106 +++++++++--------- 2 files changed, 57 insertions(+), 51 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 771dffa..c0163b0 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -27,6 +27,8 @@ public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnE public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, CancellationToken cancellationToken = default) { var messageId = message.MessageId ?? Guid.NewGuid().ToString("N"); + + //TODO: How do we simulate an exception? should we even support the old legacy asb format var body = GetBody(message); //TODO: Should we get the headers up here as well diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 3d8f021..38ce66b 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -12,65 +12,73 @@ public class MessageProcessorTests [Test] public async Task ShouldCallCompleteWhenOnMessageSucceeds() { - var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver()); - - var onErrorWasCalled = false; - var onMessageWasCalled = false; - await processor.Initialize(PushRuntimeSettings.Default, - (_, _) => - { - onMessageWasCalled = true; - return Task.CompletedTask; - }, (_, _) => - { - onErrorWasCalled = true; - return Task.FromResult(ErrorHandleResult.Handled); - }); - - var serviceBusReceivedMessage = ServiceBusModelFactory.ServiceBusReceivedMessage(); - var messageActions = new TestableMessageActions(); - - await processor.Process(serviceBusReceivedMessage, messageActions); + var result = await ProcessMessage( + onMessage: _ => Task.CompletedTask); using (Assert.EnterMultipleScope()) { - Assert.IsTrue(onMessageWasCalled); - Assert.IsFalse(onErrorWasCalled); - Assert.IsTrue(messageActions.WasCompleted); - Assert.IsFalse(messageActions.WasAbandoned); + Assert.IsTrue(result.OnMessageWasCalled); + Assert.IsFalse(result.OnErrorWasCalled); + Assert.IsTrue(result.MessageActions.WasCompleted); + Assert.IsFalse(result.MessageActions.WasAbandoned); } } [Test] public async Task ShouldCallAbandonWhenOnMessageFailsAndRetryIsRequested() { + var result = await ProcessMessage( + onMessage: _ => throw new Exception("simulated exception")); + + using (Assert.EnterMultipleScope()) + { + Assert.IsTrue(result.OnMessageWasCalled); + Assert.IsTrue(result.OnErrorWasCalled); + Assert.IsFalse(result.MessageActions.WasCompleted); + Assert.IsTrue(result.MessageActions.WasAbandoned); + } + } + +#pragma warning disable CS8425 // Func used as a method parameter with a Task return type argument should have at least one CancellationToken parameter type argument +#pragma warning disable PS0013 + async Task ProcessMessage( + ServiceBusReceivedMessage? message = null, + Func? onMessage = null, + Func>? onError = null) + { + message ??= ServiceBusModelFactory.ServiceBusReceivedMessage(); + onMessage ??= _ => Task.CompletedTask; + onError ??= _ => Task.FromResult(ErrorHandleResult.RetryRequired); + var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver()); + bool onMessageWasCalled = false; + bool onErrorWasCalled = false; + + var messageActions = new TestableMessageActions(); - var onErrorWasCalled = false; - var onMessageWasCalled = false; await processor.Initialize(PushRuntimeSettings.Default, - (_, _) => + async (msgContext, _) => { onMessageWasCalled = true; - throw new Exception("simulated exception"); - }, (_, _) => + await onMessage(msgContext); + }, + async (errorContext, _) => { onErrorWasCalled = true; - return Task.FromResult(ErrorHandleResult.RetryRequired); + return await onError(errorContext); }); - var serviceBusReceivedMessage = ServiceBusModelFactory.ServiceBusReceivedMessage(); - var messageActions = new TestableMessageActions(); - - await processor.Process(serviceBusReceivedMessage, messageActions); + await processor.Process(message, messageActions); + return new ProcessingResult(onMessageWasCalled, onErrorWasCalled, messageActions); + } +#pragma warning restore PS0013 +#pragma warning restore CS8425 - using (Assert.EnterMultipleScope()) - { - Assert.IsTrue(onMessageWasCalled); - Assert.IsTrue(onErrorWasCalled); - Assert.IsFalse(messageActions.WasCompleted); - Assert.IsTrue(messageActions.WasAbandoned); - } + class ProcessingResult(bool onMessageWasCalled, bool onErrorWasCalled, TestableMessageActions messageActions) + { + public bool OnMessageWasCalled { get; } = onMessageWasCalled; + public bool OnErrorWasCalled { get; } = onErrorWasCalled; + public TestableMessageActions MessageActions { get; } = messageActions; } class TestableMessageActions : ServiceBusMessageActions @@ -94,16 +102,12 @@ class TestableMessageActions : ServiceBusMessageActions class FakeBaseReceiver : IMessageReceiver { - public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnError onError, CancellationToken cancellationToken = new CancellationToken()) => Task.CompletedTask; - - public Task StartReceive(CancellationToken cancellationToken = new CancellationToken()) => throw new NotImplementedException(); - - public Task ChangeConcurrency(PushRuntimeSettings limitations, CancellationToken cancellationToken = new CancellationToken()) => throw new NotImplementedException(); - - public Task StopReceive(CancellationToken cancellationToken = new CancellationToken()) => throw new NotImplementedException(); - - public ISubscriptionManager Subscriptions { get; } - public string Id { get; } - public string ReceiveAddress { get; } = "TestEndpoint"; + public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnError onError, CancellationToken cancellationToken = new()) => Task.CompletedTask; + public Task StartReceive(CancellationToken cancellationToken = new()) => throw new NotImplementedException(); + public Task ChangeConcurrency(PushRuntimeSettings limitations, CancellationToken cancellationToken = new()) => throw new NotImplementedException(); + public Task StopReceive(CancellationToken cancellationToken = new()) => throw new NotImplementedException(); + public ISubscriptionManager Subscriptions => null!; + public string Id => string.Empty; + public string ReceiveAddress => "TestEndpoint"; } } \ No newline at end of file From 58f937b2d545d737eaf71d8c36882266ca3d8f33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 06:43:08 +0100 Subject: [PATCH 07/68] Add test for ErrorHandleResult.Handled --- src/Tests/MessageProcessorTests.cs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 38ce66b..3d71dc3 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -10,7 +10,7 @@ namespace NServiceBus.AzureFunctions.Tests; public class MessageProcessorTests { [Test] - public async Task ShouldCallCompleteWhenOnMessageSucceeds() + public async Task Should_complete_when_on_message_succeeds() { var result = await ProcessMessage( onMessage: _ => Task.CompletedTask); @@ -25,7 +25,7 @@ public async Task ShouldCallCompleteWhenOnMessageSucceeds() } [Test] - public async Task ShouldCallAbandonWhenOnMessageFailsAndRetryIsRequested() + public async Task Should_abandon_when_on_message_fails_and_retry_is_requested() { var result = await ProcessMessage( onMessage: _ => throw new Exception("simulated exception")); @@ -39,6 +39,30 @@ public async Task ShouldCallAbandonWhenOnMessageFailsAndRetryIsRequested() } } + [Test] + public async Task Should_complete_when_on_message_fails_and_failure_is_marked_as_handled() + { + var result = await ProcessMessage( + onMessage: _ => throw new Exception("simulated exception"), + onError: _ => Task.FromResult(ErrorHandleResult.Handled)); + + using (Assert.EnterMultipleScope()) + { + Assert.IsTrue(result.OnMessageWasCalled); + Assert.IsTrue(result.OnErrorWasCalled); + Assert.IsTrue(result.MessageActions.WasCompleted); + Assert.IsFalse(result.MessageActions.WasAbandoned); + } + } + + //TODO: Tests to add + // ShouldExposeServiceBusMessageOnBothMessageAndErrorContext + // ShouldSupportLegacyWcfBody ? + // ShouldDefaultMessageIdToNewGuid + // ShouldNotInvokeOnErrorIfCancellationIsRequested + // ShouldDLQMessageIfBodyOrHeaderExtractionFails? + // ShouldNotAllowHeaderOrBodyMutationsAcrossOnMessageAndOnError + #pragma warning disable CS8425 // Func used as a method parameter with a Task return type argument should have at least one CancellationToken parameter type argument #pragma warning disable PS0013 async Task ProcessMessage( From 1b3fa2e57d1671b5ac8afce3bba84a9b0e070b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 06:56:45 +0100 Subject: [PATCH 08/68] Add test to check exposure of incoming message --- src/Tests/MessageProcessorTests.cs | 36 +++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 3d71dc3..e29a1ae 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -55,8 +55,23 @@ public async Task Should_complete_when_on_message_fails_and_failure_is_marked_as } } + [Test] + public async Task Should_expose_servicebus_message_on_both_message_and_error_context() + { + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(); + + var result = await ProcessMessage( + message: message, + onMessage: _ => throw new Exception("simulated exception")); + + using (Assert.EnterMultipleScope()) + { + Assert.AreSame(message, result.MessageContext?.Extensions.Get()); + Assert.AreSame(message, result.ErrorContext?.Extensions.Get()); + } + } + //TODO: Tests to add - // ShouldExposeServiceBusMessageOnBothMessageAndErrorContext // ShouldSupportLegacyWcfBody ? // ShouldDefaultMessageIdToNewGuid // ShouldNotInvokeOnErrorIfCancellationIsRequested @@ -75,34 +90,35 @@ async Task ProcessMessage( onError ??= _ => Task.FromResult(ErrorHandleResult.RetryRequired); var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver()); - bool onMessageWasCalled = false; - bool onErrorWasCalled = false; - + MessageContext? capturedMessageContext = null; + ErrorContext? capturedErrorContext = null; var messageActions = new TestableMessageActions(); await processor.Initialize(PushRuntimeSettings.Default, async (msgContext, _) => { - onMessageWasCalled = true; + capturedMessageContext = msgContext; await onMessage(msgContext); }, async (errorContext, _) => { - onErrorWasCalled = true; + capturedErrorContext = errorContext; return await onError(errorContext); }); await processor.Process(message, messageActions); - return new ProcessingResult(onMessageWasCalled, onErrorWasCalled, messageActions); + return new ProcessingResult(messageActions, capturedMessageContext, capturedErrorContext); } #pragma warning restore PS0013 #pragma warning restore CS8425 - class ProcessingResult(bool onMessageWasCalled, bool onErrorWasCalled, TestableMessageActions messageActions) + class ProcessingResult(TestableMessageActions messageActions, MessageContext? messageContext, ErrorContext? errorContext) { - public bool OnMessageWasCalled { get; } = onMessageWasCalled; - public bool OnErrorWasCalled { get; } = onErrorWasCalled; public TestableMessageActions MessageActions { get; } = messageActions; + public MessageContext? MessageContext { get; } = messageContext; + public ErrorContext? ErrorContext { get; } = errorContext; + public bool OnMessageWasCalled => MessageContext != null; + public bool OnErrorWasCalled => ErrorContext != null; } class TestableMessageActions : ServiceBusMessageActions From e762bb4b8cbcb3042c9b1bac50f67e9ede5d3a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 07:09:50 +0100 Subject: [PATCH 09/68] Add test to check cancellation behavior --- src/Tests/MessageProcessorTests.cs | 88 ++++++++++++++++++------------ 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index e29a1ae..25f5b57 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -13,14 +13,14 @@ public class MessageProcessorTests public async Task Should_complete_when_on_message_succeeds() { var result = await ProcessMessage( - onMessage: _ => Task.CompletedTask); + onMessage: (_, _) => Task.CompletedTask); using (Assert.EnterMultipleScope()) { - Assert.IsTrue(result.OnMessageWasCalled); - Assert.IsFalse(result.OnErrorWasCalled); - Assert.IsTrue(result.MessageActions.WasCompleted); - Assert.IsFalse(result.MessageActions.WasAbandoned); + Assert.IsTrue(result.OnMessageWasCalled, "OnMessage should be called"); + Assert.IsFalse(result.OnErrorWasCalled, "OnError should not be called"); + Assert.IsTrue(result.MessageActions.WasCompleted, "Message should be completed"); + Assert.IsFalse(result.MessageActions.WasAbandoned, "Message should not be abandoned"); } } @@ -28,14 +28,14 @@ public async Task Should_complete_when_on_message_succeeds() public async Task Should_abandon_when_on_message_fails_and_retry_is_requested() { var result = await ProcessMessage( - onMessage: _ => throw new Exception("simulated exception")); + onMessage: (_, _) => throw new Exception("simulated exception")); using (Assert.EnterMultipleScope()) { - Assert.IsTrue(result.OnMessageWasCalled); - Assert.IsTrue(result.OnErrorWasCalled); - Assert.IsFalse(result.MessageActions.WasCompleted); - Assert.IsTrue(result.MessageActions.WasAbandoned); + Assert.IsTrue(result.OnMessageWasCalled, "OnMessage should be called"); + Assert.IsTrue(result.OnErrorWasCalled, "OnError should be called"); + Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); + Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned"); } } @@ -43,31 +43,51 @@ public async Task Should_abandon_when_on_message_fails_and_retry_is_requested() public async Task Should_complete_when_on_message_fails_and_failure_is_marked_as_handled() { var result = await ProcessMessage( - onMessage: _ => throw new Exception("simulated exception"), - onError: _ => Task.FromResult(ErrorHandleResult.Handled)); + onMessage: (_, _) => throw new Exception("simulated exception"), + onError: (_, _) => Task.FromResult(ErrorHandleResult.Handled)); using (Assert.EnterMultipleScope()) { - Assert.IsTrue(result.OnMessageWasCalled); - Assert.IsTrue(result.OnErrorWasCalled); - Assert.IsTrue(result.MessageActions.WasCompleted); - Assert.IsFalse(result.MessageActions.WasAbandoned); + Assert.IsTrue(result.OnMessageWasCalled, "OnMessage should be called"); + Assert.IsTrue(result.OnErrorWasCalled, "OnError should be called"); + Assert.IsTrue(result.MessageActions.WasCompleted, "Message should be completed"); + Assert.IsFalse(result.MessageActions.WasAbandoned, "Message should not be abandoned"); } } [Test] - public async Task Should_expose_servicebus_message_on_both_message_and_error_context() + public async Task Should_expose_the_service_bus_message_on_both_message_and_error_context() { var message = ServiceBusModelFactory.ServiceBusReceivedMessage(); var result = await ProcessMessage( message: message, - onMessage: _ => throw new Exception("simulated exception")); + onMessage: (_, _) => throw new Exception("simulated exception")); using (Assert.EnterMultipleScope()) { - Assert.AreSame(message, result.MessageContext?.Extensions.Get()); - Assert.AreSame(message, result.ErrorContext?.Extensions.Get()); + Assert.AreSame(message, result.MessageContext?.Extensions.Get(), "MessageContext should contain the ServiceBusReceivedMessage"); + Assert.AreSame(message, result.ErrorContext?.Extensions.Get(), "ErrorContext should contain the ServiceBusReceivedMessage"); + } + } + + [Test] + public async Task Should_abandon_when_token_is_cancelled_and_not_invoke_onerror() + { + var result = await ProcessMessage( + onMessage: (_, ct) => + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + }, + cancellationToken: new CancellationToken(true) + ); + + using (Assert.EnterMultipleScope()) + { + Assert.IsFalse(result.OnErrorWasCalled, "OnError should not be called"); + Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); + Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned"); } } @@ -78,16 +98,17 @@ public async Task Should_expose_servicebus_message_on_both_message_and_error_con // ShouldDLQMessageIfBodyOrHeaderExtractionFails? // ShouldNotAllowHeaderOrBodyMutationsAcrossOnMessageAndOnError -#pragma warning disable CS8425 // Func used as a method parameter with a Task return type argument should have at least one CancellationToken parameter type argument -#pragma warning disable PS0013 async Task ProcessMessage( ServiceBusReceivedMessage? message = null, - Func? onMessage = null, - Func>? onError = null) + Func? onMessage = null, + Func>? onError = null, +#pragma warning disable PS0004 + CancellationToken cancellationToken = default) +#pragma warning restore PS0004 { message ??= ServiceBusModelFactory.ServiceBusReceivedMessage(); - onMessage ??= _ => Task.CompletedTask; - onError ??= _ => Task.FromResult(ErrorHandleResult.RetryRequired); + onMessage ??= (_, _) => Task.CompletedTask; + onError ??= (_, _) => Task.FromResult(ErrorHandleResult.RetryRequired); var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver()); MessageContext? capturedMessageContext = null; @@ -95,22 +116,21 @@ async Task ProcessMessage( var messageActions = new TestableMessageActions(); await processor.Initialize(PushRuntimeSettings.Default, - async (msgContext, _) => + async (msgContext, token) => { capturedMessageContext = msgContext; - await onMessage(msgContext); + await onMessage(msgContext, token); }, - async (errorContext, _) => + async (errorContext, token) => { capturedErrorContext = errorContext; - return await onError(errorContext); - }); + return await onError(errorContext, token); + }, + cancellationToken); - await processor.Process(message, messageActions); + await processor.Process(message, messageActions, cancellationToken); return new ProcessingResult(messageActions, capturedMessageContext, capturedErrorContext); } -#pragma warning restore PS0013 -#pragma warning restore CS8425 class ProcessingResult(TestableMessageActions messageActions, MessageContext? messageContext, ErrorContext? errorContext) { From ef484fae086e11cba2752b63c80fe91bb5ee59df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 07:33:18 +0100 Subject: [PATCH 10/68] Add test to check body and headers --- src/Tests/MessageProcessorTests.cs | 32 +++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 25f5b57..092d4ec 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -4,6 +4,7 @@ namespace NServiceBus.AzureFunctions.Tests; using AzureServiceBus.Serverless.TransportWrapper; using Microsoft.Azure.Functions.Worker; using NUnit.Framework; +using NUnit.Framework.Legacy; using Transport; [TestFixture] @@ -91,10 +92,39 @@ public async Task Should_abandon_when_token_is_cancelled_and_not_invoke_onerror( } } + [Test] + public async Task Should_expose_native_message_id_headers_and_body_on_message_context() + { + var expectedMessageId = "test-message-id-123"; + var expectedBody = new byte[] { 1, 2, 3, 4 }; + var expectedHeaderKey = "custom-header"; + var expectedHeaderValue = "header-value"; + + var message = ServiceBusModelFactory.ServiceBusReceivedMessage( + messageId: expectedMessageId, + properties: new Dictionary { { expectedHeaderKey, expectedHeaderValue } }, + body: new BinaryData(expectedBody) + ); + + var result = await ProcessMessage( + message: message + ); + + var messageContext = result.MessageContext; + + using (Assert.EnterMultipleScope()) + { + Assert.NotNull(messageContext, "MessageContext should not be null"); + Assert.AreEqual(expectedMessageId, messageContext!.NativeMessageId, "MessageContext should expose the native message id"); + Assert.IsTrue(messageContext.Headers.ContainsKey(expectedHeaderKey), "MessageContext should expose the custom header"); + Assert.AreEqual(expectedHeaderValue, messageContext.Headers[expectedHeaderKey], "MessageContext should expose the correct header value"); + Assert.That(messageContext.Body.ToArray(), Is.EqualTo(expectedBody).AsCollection, "MessageContext should expose the correct message body"); + } + } + //TODO: Tests to add // ShouldSupportLegacyWcfBody ? // ShouldDefaultMessageIdToNewGuid - // ShouldNotInvokeOnErrorIfCancellationIsRequested // ShouldDLQMessageIfBodyOrHeaderExtractionFails? // ShouldNotAllowHeaderOrBodyMutationsAcrossOnMessageAndOnError From 60e0592c803642528a30b8fb139e1c5f6aaeb5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 07:34:29 +0100 Subject: [PATCH 11/68] Cleanup --- src/Tests/MessageProcessorTests.cs | 61 +++++++++++++++--------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 092d4ec..b473359 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -4,12 +4,41 @@ namespace NServiceBus.AzureFunctions.Tests; using AzureServiceBus.Serverless.TransportWrapper; using Microsoft.Azure.Functions.Worker; using NUnit.Framework; -using NUnit.Framework.Legacy; using Transport; [TestFixture] public class MessageProcessorTests { + [Test] + public async Task Should_expose_native_message_id_headers_and_body_on_message_context() + { + var expectedMessageId = "test-message-id-123"; + var expectedBody = new byte[] { 1, 2, 3, 4 }; + var expectedHeaderKey = "custom-header"; + var expectedHeaderValue = "header-value"; + + var message = ServiceBusModelFactory.ServiceBusReceivedMessage( + messageId: expectedMessageId, + properties: new Dictionary { { expectedHeaderKey, expectedHeaderValue } }, + body: new BinaryData(expectedBody) + ); + + var result = await ProcessMessage( + message: message + ); + + var messageContext = result.MessageContext; + + using (Assert.EnterMultipleScope()) + { + Assert.NotNull(messageContext, "MessageContext should not be null"); + Assert.AreEqual(expectedMessageId, messageContext!.NativeMessageId, "MessageContext should expose the native message id"); + Assert.IsTrue(messageContext.Headers.ContainsKey(expectedHeaderKey), "MessageContext should expose the custom header"); + Assert.AreEqual(expectedHeaderValue, messageContext.Headers[expectedHeaderKey], "MessageContext should expose the correct header value"); + Assert.That(messageContext.Body.ToArray(), Is.EqualTo(expectedBody).AsCollection, "MessageContext should expose the correct message body"); + } + } + [Test] public async Task Should_complete_when_on_message_succeeds() { @@ -92,36 +121,6 @@ public async Task Should_abandon_when_token_is_cancelled_and_not_invoke_onerror( } } - [Test] - public async Task Should_expose_native_message_id_headers_and_body_on_message_context() - { - var expectedMessageId = "test-message-id-123"; - var expectedBody = new byte[] { 1, 2, 3, 4 }; - var expectedHeaderKey = "custom-header"; - var expectedHeaderValue = "header-value"; - - var message = ServiceBusModelFactory.ServiceBusReceivedMessage( - messageId: expectedMessageId, - properties: new Dictionary { { expectedHeaderKey, expectedHeaderValue } }, - body: new BinaryData(expectedBody) - ); - - var result = await ProcessMessage( - message: message - ); - - var messageContext = result.MessageContext; - - using (Assert.EnterMultipleScope()) - { - Assert.NotNull(messageContext, "MessageContext should not be null"); - Assert.AreEqual(expectedMessageId, messageContext!.NativeMessageId, "MessageContext should expose the native message id"); - Assert.IsTrue(messageContext.Headers.ContainsKey(expectedHeaderKey), "MessageContext should expose the custom header"); - Assert.AreEqual(expectedHeaderValue, messageContext.Headers[expectedHeaderKey], "MessageContext should expose the correct header value"); - Assert.That(messageContext.Body.ToArray(), Is.EqualTo(expectedBody).AsCollection, "MessageContext should expose the correct message body"); - } - } - //TODO: Tests to add // ShouldSupportLegacyWcfBody ? // ShouldDefaultMessageIdToNewGuid From 4f163e1e0d4296c90d2508efe3a958d69011bae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 07:35:56 +0100 Subject: [PATCH 12/68] Add todo --- .../PipelineInvokingMessageProcessor.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index c0163b0..99639ad 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -26,6 +26,7 @@ public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnE public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, CancellationToken cancellationToken = default) { + //TODO: Asb throws id message id is null, align? var messageId = message.MessageId ?? Guid.NewGuid().ToString("N"); //TODO: How do we simulate an exception? should we even support the old legacy asb format From 5ef94c103869dc6e511ff262ed3a1281206e2973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 07:45:10 +0100 Subject: [PATCH 13/68] Add tests for upconverting --- src/Tests/MessageProcessorTests.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index b473359..af37188 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -5,6 +5,7 @@ namespace NServiceBus.AzureFunctions.Tests; using Microsoft.Azure.Functions.Worker; using NUnit.Framework; using Transport; +using NServiceBus; [TestFixture] public class MessageProcessorTests @@ -16,11 +17,15 @@ public async Task Should_expose_native_message_id_headers_and_body_on_message_co var expectedBody = new byte[] { 1, 2, 3, 4 }; var expectedHeaderKey = "custom-header"; var expectedHeaderValue = "header-value"; + var expectedReplyTo = "reply-queue"; + var expectedCorrelationId = "correlation-abc"; var message = ServiceBusModelFactory.ServiceBusReceivedMessage( messageId: expectedMessageId, properties: new Dictionary { { expectedHeaderKey, expectedHeaderValue } }, - body: new BinaryData(expectedBody) + body: new BinaryData(expectedBody), + replyTo: expectedReplyTo, + correlationId: expectedCorrelationId ); var result = await ProcessMessage( @@ -36,6 +41,10 @@ public async Task Should_expose_native_message_id_headers_and_body_on_message_co Assert.IsTrue(messageContext.Headers.ContainsKey(expectedHeaderKey), "MessageContext should expose the custom header"); Assert.AreEqual(expectedHeaderValue, messageContext.Headers[expectedHeaderKey], "MessageContext should expose the correct header value"); Assert.That(messageContext.Body.ToArray(), Is.EqualTo(expectedBody).AsCollection, "MessageContext should expose the correct message body"); + Assert.IsTrue(messageContext.Headers.ContainsKey(Headers.CorrelationId), "Native CorrelationId should be upconverted to a the CorrelationId header"); + Assert.AreEqual(expectedCorrelationId, messageContext.Headers[Headers.CorrelationId], "Headers should expose the correct CorrelationId value"); + Assert.IsTrue(messageContext.Headers.ContainsKey(Headers.ReplyToAddress), "Native ReplyTo should be upconverted to a the ReplyToAddress header"); + Assert.AreEqual(expectedReplyTo, messageContext.Headers[Headers.ReplyToAddress], "Headers should expose the correct ReplyTo header value"); } } From 160c6b0321e0003131dc201dfaf010c67bc46311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 08:03:09 +0100 Subject: [PATCH 14/68] Add comment for unique transport transaction in message processing tests --- src/Tests/MessageProcessorTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index af37188..b8392b3 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -135,6 +135,7 @@ public async Task Should_abandon_when_token_is_cancelled_and_not_invoke_onerror( // ShouldDefaultMessageIdToNewGuid // ShouldDLQMessageIfBodyOrHeaderExtractionFails? // ShouldNotAllowHeaderOrBodyMutationsAcrossOnMessageAndOnError + // Should_expose_a_unique_transport_transaction_for_onmessage_and_onerror? async Task ProcessMessage( ServiceBusReceivedMessage? message = null, From e835868ed15fe79104227db6dba7414287661619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 14:01:50 +0100 Subject: [PATCH 15/68] Remove support for legacy body format --- .../PipelineInvokingMessageProcessor.cs | 31 ++----------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 99639ad..7832ed2 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -1,10 +1,8 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus.Serverless.TransportWrapper; using System; -using System.Runtime.Serialization; using System.Threading; using System.Threading.Tasks; -using System.Xml; using Azure.Messaging.ServiceBus; using Microsoft.Azure.Functions.Worker; using NServiceBus.Extensibility; @@ -26,11 +24,11 @@ public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnE public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, CancellationToken cancellationToken = default) { - //TODO: Asb throws id message id is null, align? + //TODO: Asb throws if message id is null, align? var messageId = message.MessageId ?? Guid.NewGuid().ToString("N"); - //TODO: How do we simulate an exception? should we even support the old legacy asb format - var body = GetBody(message); + var body = message.Body ?? BinaryData.FromBytes(ReadOnlyMemory.Empty); + //TODO: Should we get the headers up here as well var contextBag = new ContextBag(); @@ -70,23 +68,6 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc } } - static BinaryData GetBody(ServiceBusReceivedMessage message) - { - var body = message.Body ?? BinaryData.FromBytes(ReadOnlyMemory.Empty); - var memory = body.ToMemory(); - - if (memory.IsEmpty || - !message.ApplicationProperties.TryGetValue(TransportEncodingHeader, out var value) || - !value.Equals("wcf/byte-array")) - { - return body; - } - - using var reader = XmlDictionaryReader.CreateBinaryReader(body.ToStream(), XmlDictionaryReaderQuotas.Max); - var bodyBytes = (byte[])Deserializer.ReadObject(reader)!; - return new BinaryData(bodyBytes); - } - ErrorContext CreateErrorContext(ServiceBusReceivedMessage message, Exception exception, string messageId, BinaryData body, TransportTransaction transportTransaction, ContextBag contextBag) => new(exception, GetNServiceBusHeaders(message), messageId, body, transportTransaction, message.DeliveryCount, ReceiveAddress, contextBag); @@ -104,8 +85,6 @@ MessageContext CreateMessageContext(ServiceBusReceivedMessage message, string me headers[kvp.Key] = kvp.Value?.ToString(); } - headers.Remove(TransportEncodingHeader); - if (!string.IsNullOrWhiteSpace(message.ReplyTo)) { headers[Headers.ReplyToAddress] = message.ReplyTo; @@ -132,8 +111,4 @@ MessageContext CreateMessageContext(ServiceBusReceivedMessage message, string me OnMessage? onMessage; OnError? onError; - - const string TransportEncodingHeader = "NServiceBus.Transport.Encoding"; - - static readonly DataContractSerializer Deserializer = new(typeof(byte[])); } \ No newline at end of file From afecf7b0456d5bef79cdfcf30a1c7a58cf6a0133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 14:17:23 +0100 Subject: [PATCH 16/68] Reguire message it to be set --- .../PipelineInvokingMessageProcessor.cs | 12 ++++--- src/Tests/MessageProcessorTests.cs | 32 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 7832ed2..540c7a9 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -24,8 +24,12 @@ public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnE public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, CancellationToken cancellationToken = default) { - //TODO: Asb throws if message id is null, align? - var messageId = message.MessageId ?? Guid.NewGuid().ToString("N"); + var nativeMessageId = message.MessageId; + if (string.IsNullOrEmpty(nativeMessageId)) + { + await messageActions.DeadLetterMessageAsync(message, deadLetterReason: "MessageId not set on message", deadLetterErrorDescription: "Azure Service Bus MessageId is required, but was not found. Ensure to assign MessageId to all Service Bus messages.", cancellationToken: cancellationToken).ConfigureAwait(false); + return; + } var body = message.Body ?? BinaryData.FromBytes(ReadOnlyMemory.Empty); @@ -39,7 +43,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc try { using var azureServiceBusTransportTransaction = new AzureServiceBusTransportTransaction(); - var messageContext = CreateMessageContext(message, messageId, body, azureServiceBusTransportTransaction.TransportTransaction, contextBag); + var messageContext = CreateMessageContext(message, nativeMessageId, body, azureServiceBusTransportTransaction.TransportTransaction, contextBag); await onMessage!(messageContext, cancellationToken).ConfigureAwait(false); @@ -53,7 +57,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc catch (Exception exception) { using var azureServiceBusTransportTransaction = new AzureServiceBusTransportTransaction(); - var errorContext = CreateErrorContext(message, exception, messageId, body, azureServiceBusTransportTransaction.TransportTransaction, contextBag); + var errorContext = CreateErrorContext(message, exception, nativeMessageId, body, azureServiceBusTransportTransaction.TransportTransaction, contextBag); var errorHandleResult = await onError!.Invoke(errorContext, cancellationToken).ConfigureAwait(false); diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index b8392b3..a2d6515 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -48,6 +48,23 @@ public async Task Should_expose_native_message_id_headers_and_body_on_message_co } } + [Test] + public async Task Should_require_native_message_id_to_be_set() + { + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(); + + var result = await ProcessMessage( + message: message + ); + + using (Assert.EnterMultipleScope()) + { + Assert.False(result.OnMessageWasCalled, "OnMessage should not be called"); + Assert.False(result.OnErrorWasCalled, "OnError should not be called"); + Assert.True(result.MessageActions.WasDeadLettered, "Missing native message id should result in message being dead lettered"); + } + } + [Test] public async Task Should_complete_when_on_message_succeeds() { @@ -97,7 +114,7 @@ public async Task Should_complete_when_on_message_fails_and_failure_is_marked_as [Test] public async Task Should_expose_the_service_bus_message_on_both_message_and_error_context() { - var message = ServiceBusModelFactory.ServiceBusReceivedMessage(); + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(messageId: Guid.NewGuid().ToString()); var result = await ProcessMessage( message: message, @@ -145,7 +162,7 @@ async Task ProcessMessage( CancellationToken cancellationToken = default) #pragma warning restore PS0004 { - message ??= ServiceBusModelFactory.ServiceBusReceivedMessage(); + message ??= ServiceBusModelFactory.ServiceBusReceivedMessage(messageId: Guid.NewGuid().ToString()); onMessage ??= (_, _) => Task.CompletedTask; onError ??= (_, _) => Task.FromResult(ErrorHandleResult.RetryRequired); @@ -167,7 +184,8 @@ await processor.Initialize(PushRuntimeSettings.Default, }, cancellationToken); - await processor.Process(message, messageActions, cancellationToken); + Assert.DoesNotThrowAsync(async () => await processor.Process(message, messageActions, cancellationToken)); + return new ProcessingResult(messageActions, capturedMessageContext, capturedErrorContext); } @@ -183,8 +201,8 @@ class ProcessingResult(TestableMessageActions messageActions, MessageContext? me class TestableMessageActions : ServiceBusMessageActions { public bool WasCompleted { get; private set; } - public bool WasAbandoned { get; private set; } + public bool WasDeadLettered { get; private set; } public override Task CompleteMessageAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = new CancellationToken()) { @@ -197,6 +215,12 @@ class TestableMessageActions : ServiceBusMessageActions WasAbandoned = true; return Task.CompletedTask; } + + public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, Dictionary? propertiesToModify = null, string? deadLetterReason = null, string? deadLetterErrorDescription = null, CancellationToken cancellationToken = new CancellationToken()) + { + WasDeadLettered = true; + return Task.CompletedTask; + } } class FakeBaseReceiver : IMessageReceiver From 6a8851efc8c259de40cd3546801e2b8eba629697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 14:19:06 +0100 Subject: [PATCH 17/68] Use cancellation token none --- .../PipelineInvokingMessageProcessor.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 540c7a9..ed0153f 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -27,7 +27,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc var nativeMessageId = message.MessageId; if (string.IsNullOrEmpty(nativeMessageId)) { - await messageActions.DeadLetterMessageAsync(message, deadLetterReason: "MessageId not set on message", deadLetterErrorDescription: "Azure Service Bus MessageId is required, but was not found. Ensure to assign MessageId to all Service Bus messages.", cancellationToken: cancellationToken).ConfigureAwait(false); + await messageActions.DeadLetterMessageAsync(message, deadLetterReason: "MessageId not set on message", deadLetterErrorDescription: "Azure Service Bus MessageId is required, but was not found. Ensure to assign MessageId to all Service Bus messages.", cancellationToken: CancellationToken.None).ConfigureAwait(false); return; } @@ -48,27 +48,27 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc await onMessage!(messageContext, cancellationToken).ConfigureAwait(false); azureServiceBusTransportTransaction.Commit(); - await messageActions.CompleteMessageAsync(message, cancellationToken).ConfigureAwait(false); + await messageActions.CompleteMessageAsync(message, CancellationToken.None).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - await messageActions.AbandonMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false); + await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); } catch (Exception exception) { using var azureServiceBusTransportTransaction = new AzureServiceBusTransportTransaction(); var errorContext = CreateErrorContext(message, exception, nativeMessageId, body, azureServiceBusTransportTransaction.TransportTransaction, contextBag); - var errorHandleResult = await onError!.Invoke(errorContext, cancellationToken).ConfigureAwait(false); + var errorHandleResult = await onError!.Invoke(errorContext, CancellationToken.None).ConfigureAwait(false); if (errorHandleResult == ErrorHandleResult.Handled) { azureServiceBusTransportTransaction.Commit(); - await messageActions.CompleteMessageAsync(message, cancellationToken).ConfigureAwait(false); + await messageActions.CompleteMessageAsync(message, CancellationToken.None).ConfigureAwait(false); return; } - await messageActions.AbandonMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false); + await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); } } From ed63a373afb5e2786c9f3e1fb74d4620fe4563c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 14:19:59 +0100 Subject: [PATCH 18/68] Cleanup --- src/Tests/NServiceBus.AzureFunctions.Tests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tests/NServiceBus.AzureFunctions.Tests.csproj b/src/Tests/NServiceBus.AzureFunctions.Tests.csproj index b28b42c..0c34cf1 100644 --- a/src/Tests/NServiceBus.AzureFunctions.Tests.csproj +++ b/src/Tests/NServiceBus.AzureFunctions.Tests.csproj @@ -23,6 +23,7 @@ + From abf6dd7b7f476cba9f48a245155add07153c68c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sat, 21 Mar 2026 14:20:13 +0100 Subject: [PATCH 19/68] More cleanup --- src/Tests/NServiceBus.AzureFunctions.Tests.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Tests/NServiceBus.AzureFunctions.Tests.csproj b/src/Tests/NServiceBus.AzureFunctions.Tests.csproj index 0c34cf1..b28b42c 100644 --- a/src/Tests/NServiceBus.AzureFunctions.Tests.csproj +++ b/src/Tests/NServiceBus.AzureFunctions.Tests.csproj @@ -23,7 +23,6 @@ - From 8c0cdd428f29c822a06c88e799087353591ded79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 07:53:45 +0100 Subject: [PATCH 20/68] Log when message without message id is dlq'd --- .../AzureServiceBusServerlessTransport.cs | 4 +++- .../PipelineInvokingMessageProcessor.cs | 9 +++++++-- src/Tests/MessageProcessorTests.cs | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs index 87a295e..5185361 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs @@ -9,6 +9,8 @@ namespace NServiceBus; using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using NServiceBus.AzureFunctions.AzureServiceBus.Serverless.TransportWrapper; using NServiceBus.Transport; @@ -53,7 +55,7 @@ public override async Task Initialize( .ConfigureAwait(false); var serverlessTransportInfrastructure = new ServerlessTransportInfrastructure(baseTransportInfrastructure, - static receiver => new PipelineInvokingMessageProcessor(receiver)); + receiver => new PipelineInvokingMessageProcessor(receiver, hostSettings.ServiceProvider.GetRequiredService>())); var isSendOnly = hostSettings.CoreSettings.GetOrDefault(SendOnlyConfigKey); diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index ed0153f..32d37d2 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -5,11 +5,12 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus.Serverless.TransportWrapper using System.Threading.Tasks; using Azure.Messaging.ServiceBus; using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; using NServiceBus.Extensibility; using NServiceBus.Transport; using NServiceBus.Transport.AzureServiceBus; -class PipelineInvokingMessageProcessor(IMessageReceiver baseTransportReceiver) : IMessageReceiver +class PipelineInvokingMessageProcessor(IMessageReceiver baseTransportReceiver, ILogger logger) : IMessageReceiver { public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnError onError, CancellationToken cancellationToken = default) @@ -27,7 +28,11 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc var nativeMessageId = message.MessageId; if (string.IsNullOrEmpty(nativeMessageId)) { - await messageActions.DeadLetterMessageAsync(message, deadLetterReason: "MessageId not set on message", deadLetterErrorDescription: "Azure Service Bus MessageId is required, but was not found. Ensure to assign MessageId to all Service Bus messages.", cancellationToken: CancellationToken.None).ConfigureAwait(false); + const string deadLetterErrorDescription = "Azure Service Bus MessageId is required, but was not found. Ensure to assign MessageId to all Service Bus messages."; + + await messageActions.DeadLetterMessageAsync(message, deadLetterReason: "MessageId not set on message", deadLetterErrorDescription: deadLetterErrorDescription, cancellationToken: CancellationToken.None).ConfigureAwait(false); + + logger.LogError(deadLetterErrorDescription); return; } diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index a2d6515..7ff71f2 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -3,6 +3,7 @@ namespace NServiceBus.AzureFunctions.Tests; using Azure.Messaging.ServiceBus; using AzureServiceBus.Serverless.TransportWrapper; using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using Transport; using NServiceBus; @@ -166,7 +167,7 @@ async Task ProcessMessage( onMessage ??= (_, _) => Task.CompletedTask; onError ??= (_, _) => Task.FromResult(ErrorHandleResult.RetryRequired); - var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver()); + var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver(), NullLogger.Instance); MessageContext? capturedMessageContext = null; ErrorContext? capturedErrorContext = null; var messageActions = new TestableMessageActions(); From 0fbd59d349ab425e01151ae7896bf8ff6d16cfca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 07:56:07 +0100 Subject: [PATCH 21/68] Silence PS0004 in tests --- src/Tests/.editorconfig | 5 ++++- src/Tests/MessageProcessorTests.cs | 2 -- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Tests/.editorconfig b/src/Tests/.editorconfig index cc3614b..c788229 100644 --- a/src/Tests/.editorconfig +++ b/src/Tests/.editorconfig @@ -4,4 +4,7 @@ dotnet_diagnostic.CA2007.severity = none # Add a CancellationToken - not a library -dotnet_diagnostic.PS0018.severity = none \ No newline at end of file +dotnet_diagnostic.PS0018.severity = none + +# Make the CancellationToken required - not a library +dotnet_diagnostic.PS0004.severity = none \ No newline at end of file diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 7ff71f2..2087f42 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -159,9 +159,7 @@ async Task ProcessMessage( ServiceBusReceivedMessage? message = null, Func? onMessage = null, Func>? onError = null, -#pragma warning disable PS0004 CancellationToken cancellationToken = default) -#pragma warning restore PS0004 { message ??= ServiceBusModelFactory.ServiceBusReceivedMessage(messageId: Guid.NewGuid().ToString()); onMessage ??= (_, _) => Task.CompletedTask; From c8f0450143f20fe37c82f0b30cd81b4a1cc62b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 07:57:22 +0100 Subject: [PATCH 22/68] Include scopes --- src/IntegrationTest/Program.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/IntegrationTest/Program.cs b/src/IntegrationTest/Program.cs index b91013a..fc0729c 100644 --- a/src/IntegrationTest/Program.cs +++ b/src/IntegrationTest/Program.cs @@ -9,7 +9,7 @@ var builder = FunctionsApplication.CreateBuilder(args); builder.UseWhen(_ => true); -builder.Logging.AddSimpleConsole(); +builder.Logging.AddSimpleConsole(options => options.IncludeScopes = true); builder.Logging.SetMinimumLevel(LogLevel.Warning); builder.Services.AddSingleton(); @@ -30,4 +30,4 @@ var host = builder.Build(); -await host.RunAsync(); +await host.RunAsync(); \ No newline at end of file From 885e4516b2cfce9863e279696b738b0602ea6145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 07:58:35 +0100 Subject: [PATCH 23/68] Remove handled todos --- src/Tests/MessageProcessorTests.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 2087f42..c319ba6 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -149,9 +149,7 @@ public async Task Should_abandon_when_token_is_cancelled_and_not_invoke_onerror( } //TODO: Tests to add - // ShouldSupportLegacyWcfBody ? - // ShouldDefaultMessageIdToNewGuid - // ShouldDLQMessageIfBodyOrHeaderExtractionFails? + // ShouldDLQHeaderExtractionFails? // ShouldNotAllowHeaderOrBodyMutationsAcrossOnMessageAndOnError // Should_expose_a_unique_transport_transaction_for_onmessage_and_onerror? From 82ea87e1b0a3c3ee85557e9d95cf7498bc5bbb59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 08:03:01 +0100 Subject: [PATCH 24/68] Formatting --- src/IntegrationTest/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/IntegrationTest/Program.cs b/src/IntegrationTest/Program.cs index fc0729c..3fc5d33 100644 --- a/src/IntegrationTest/Program.cs +++ b/src/IntegrationTest/Program.cs @@ -30,4 +30,4 @@ var host = builder.Build(); -await host.RunAsync(); \ No newline at end of file +await host.RunAsync(); From b81d4c8661255d4bd8a8ff4ee0e0b388a21e7b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 08:11:06 +0100 Subject: [PATCH 25/68] Upconvert content type as well to align with the transport --- .../PipelineInvokingMessageProcessor.cs | 5 +++++ src/Tests/MessageProcessorTests.cs | 10 +++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 32d37d2..cd32d69 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -104,6 +104,11 @@ MessageContext CreateMessageContext(ServiceBusReceivedMessage message, string me headers[Headers.CorrelationId] = message.CorrelationId; } + if (!string.IsNullOrWhiteSpace(message.ContentType)) + { + headers[Headers.ContentType] = message.ContentType; + } + return headers; } diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index c319ba6..e1ca2db 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -20,13 +20,15 @@ public async Task Should_expose_native_message_id_headers_and_body_on_message_co var expectedHeaderValue = "header-value"; var expectedReplyTo = "reply-queue"; var expectedCorrelationId = "correlation-abc"; + var expectedContentType = "some/content-type"; var message = ServiceBusModelFactory.ServiceBusReceivedMessage( messageId: expectedMessageId, properties: new Dictionary { { expectedHeaderKey, expectedHeaderValue } }, body: new BinaryData(expectedBody), replyTo: expectedReplyTo, - correlationId: expectedCorrelationId + correlationId: expectedCorrelationId, + contentType: expectedContentType ); var result = await ProcessMessage( @@ -42,10 +44,12 @@ public async Task Should_expose_native_message_id_headers_and_body_on_message_co Assert.IsTrue(messageContext.Headers.ContainsKey(expectedHeaderKey), "MessageContext should expose the custom header"); Assert.AreEqual(expectedHeaderValue, messageContext.Headers[expectedHeaderKey], "MessageContext should expose the correct header value"); Assert.That(messageContext.Body.ToArray(), Is.EqualTo(expectedBody).AsCollection, "MessageContext should expose the correct message body"); - Assert.IsTrue(messageContext.Headers.ContainsKey(Headers.CorrelationId), "Native CorrelationId should be upconverted to a the CorrelationId header"); + Assert.IsTrue(messageContext.Headers.ContainsKey(Headers.CorrelationId), "Native CorrelationId should be upconverted to the CorrelationId header"); Assert.AreEqual(expectedCorrelationId, messageContext.Headers[Headers.CorrelationId], "Headers should expose the correct CorrelationId value"); - Assert.IsTrue(messageContext.Headers.ContainsKey(Headers.ReplyToAddress), "Native ReplyTo should be upconverted to a the ReplyToAddress header"); + Assert.IsTrue(messageContext.Headers.ContainsKey(Headers.ReplyToAddress), "Native ReplyTo should be upconverted to the ReplyToAddress header"); Assert.AreEqual(expectedReplyTo, messageContext.Headers[Headers.ReplyToAddress], "Headers should expose the correct ReplyTo header value"); + Assert.IsTrue(messageContext.Headers.ContainsKey(Headers.ContentType), "Native ContentType should be upconverted to the ContentType header"); + Assert.AreEqual(expectedContentType, messageContext.Headers[Headers.ContentType], "Headers should expose the correct ContentType header value"); } } From 2f41e866c713661137a2d7be57503150ff28e770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 13:07:54 +0100 Subject: [PATCH 26/68] Support deadlettering to be requested via onError --- .../AzureServiceBusServerlessTransport.cs | 3 +- .../DeadLetterRequest.cs | 3 ++ .../PipelineInvokingMessageProcessor.cs | 8 ++++- src/Tests/MessageProcessorTests.cs | 31 +++++++++++++++++-- 4 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs index 5185361..6cd3fcc 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs @@ -6,12 +6,11 @@ namespace NServiceBus; using System.Threading; using System.Threading.Tasks; using Azure.Core; +using AzureFunctions.AzureServiceBus; using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using NServiceBus.AzureFunctions.AzureServiceBus.Serverless.TransportWrapper; using NServiceBus.Transport; public class AzureServiceBusServerlessTransport(TopicTopology topology) : TransportDefinition(TransportTransactionMode.ReceiveOnly, diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs new file mode 100644 index 0000000..f3933a3 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs @@ -0,0 +1,3 @@ +namespace NServiceBus.AzureFunctions.AzureServiceBus; + +record DeadLetterRequest(string DeadLetterReason, string DeadLetterErrorDescription, Dictionary? PropertiesToModify); \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index cd32d69..67ae827 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -1,4 +1,4 @@ -namespace NServiceBus.AzureFunctions.AzureServiceBus.Serverless.TransportWrapper; +namespace NServiceBus.AzureFunctions.AzureServiceBus; using System; using System.Threading; @@ -66,6 +66,12 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc var errorHandleResult = await onError!.Invoke(errorContext, CancellationToken.None).ConfigureAwait(false); + if (errorContext.TransportTransaction.TryGet(out var deadLetterRequest)) + { + await messageActions.DeadLetterMessageAsync(message, deadLetterRequest.PropertiesToModify, deadLetterRequest.DeadLetterReason, deadLetterRequest.DeadLetterErrorDescription, cancellationToken: CancellationToken.None).ConfigureAwait(false); + return; + } + if (errorHandleResult == ErrorHandleResult.Handled) { azureServiceBusTransportTransaction.Commit(); diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index e1ca2db..6bddac1 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -1,7 +1,7 @@ namespace NServiceBus.AzureFunctions.Tests; using Azure.Messaging.ServiceBus; -using AzureServiceBus.Serverless.TransportWrapper; +using AzureServiceBus; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; @@ -116,6 +116,28 @@ public async Task Should_complete_when_on_message_fails_and_failure_is_marked_as } } + [Test] + public async Task Should_dlq_message_if_requested() + { + var result = await ProcessMessage( + onMessage: (_, _) => throw new Exception("simulated exception"), + onError: (errorContext, _) => + { + errorContext.TransportTransaction.Set(new DeadLetterRequest("reason", "description", new Dictionary { { "MyProperty", "MyValue" } })); + return Task.FromResult(ErrorHandleResult.Handled); + }); + + using (Assert.EnterMultipleScope()) + { + Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); + Assert.IsFalse(result.MessageActions.WasAbandoned, "Message should not be abandoned"); + Assert.IsTrue(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); + Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterReason, "reason"); + Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterErrorDescription, "description"); + Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterProperties?["MyProperty"], "MyValue"); + } + } + [Test] public async Task Should_expose_the_service_bus_message_on_both_message_and_error_context() { @@ -203,7 +225,8 @@ class TestableMessageActions : ServiceBusMessageActions { public bool WasCompleted { get; private set; } public bool WasAbandoned { get; private set; } - public bool WasDeadLettered { get; private set; } + public bool WasDeadLettered => DeadLetterDetails is not null; + public DeadLetterDetails? DeadLetterDetails { get; private set; } public override Task CompleteMessageAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = new CancellationToken()) { @@ -219,11 +242,13 @@ class TestableMessageActions : ServiceBusMessageActions public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, Dictionary? propertiesToModify = null, string? deadLetterReason = null, string? deadLetterErrorDescription = null, CancellationToken cancellationToken = new CancellationToken()) { - WasDeadLettered = true; + DeadLetterDetails = new(deadLetterReason, deadLetterErrorDescription, propertiesToModify); return Task.CompletedTask; } } + record DeadLetterDetails(string? DeadLetterReason, string? DeadLetterErrorDescription, Dictionary? DeadLetterProperties); + class FakeBaseReceiver : IMessageReceiver { public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnError onError, CancellationToken cancellationToken = new()) => Task.CompletedTask; From c4cbc67fae59dd188aaac7adc5430ebc90c3be23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 16:56:06 +0100 Subject: [PATCH 27/68] Add test for message abandonment when onError throws an exception --- .../PipelineInvokingMessageProcessor.cs | 21 +++++++++++++++++-- src/Tests/MessageProcessorTests.cs | 17 +++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 67ae827..fb5ea1a 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -57,14 +57,31 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + //TODO: Should we log? await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); } catch (Exception exception) { using var azureServiceBusTransportTransaction = new AzureServiceBusTransportTransaction(); var errorContext = CreateErrorContext(message, exception, nativeMessageId, body, azureServiceBusTransportTransaction.TransportTransaction, contextBag); - - var errorHandleResult = await onError!.Invoke(errorContext, CancellationToken.None).ConfigureAwait(false); + ErrorHandleResult errorHandleResult; + try + { + errorHandleResult = await onError!.Invoke(errorContext, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + //TODO: Should we log? + await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); + return; + } + catch (Exception ex) + { + //TODO: The transport has a circuit breaker for repeated failures, should we go with something similar? + await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); + logger.LogWarning(ex, "Failed to execute onError"); + return; + } if (errorContext.TransportTransaction.TryGet(out var deadLetterRequest)) { diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 6bddac1..6ddfcbb 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -116,6 +116,23 @@ public async Task Should_complete_when_on_message_fails_and_failure_is_marked_as } } + [Test] + public async Task Should_abandon_when_on_error_throws() + { + var result = await ProcessMessage( + onMessage: (_, _) => throw new Exception("simulated exception"), + onError: (_, _) => throw new Exception("simulated onError failure")); + + using (Assert.EnterMultipleScope()) + { + Assert.IsTrue(result.OnMessageWasCalled, "OnMessage should be called"); + Assert.IsTrue(result.OnErrorWasCalled, "OnError should be called"); + Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); + Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned if onError throws"); + Assert.IsFalse(result.MessageActions.WasDeadLettered, "Message should not be dead lettered"); + } + } + [Test] public async Task Should_dlq_message_if_requested() { From 9bc94cc39ab0bab640ba5ec16b364898df83aee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 17:12:19 +0100 Subject: [PATCH 28/68] Enhance logging for dead lettering and add test coverage for message processing --- .../PipelineInvokingMessageProcessor.cs | 2 + src/Tests/MessageProcessorTests.cs | 56 ++++++++++++++----- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index fb5ea1a..8d73dc4 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -86,6 +86,8 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc if (errorContext.TransportTransaction.TryGet(out var deadLetterRequest)) { await messageActions.DeadLetterMessageAsync(message, deadLetterRequest.PropertiesToModify, deadLetterRequest.DeadLetterReason, deadLetterRequest.DeadLetterErrorDescription, cancellationToken: CancellationToken.None).ConfigureAwait(false); + + logger.LogError($"Message {nativeMessageId} was dead lettered due to {deadLetterRequest.DeadLetterReason}: {deadLetterRequest.DeadLetterErrorDescription}"); return; } diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 6ddfcbb..9b1d9e2 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -3,6 +3,7 @@ namespace NServiceBus.AzureFunctions.Tests; using Azure.Messaging.ServiceBus; using AzureServiceBus; using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using Transport; @@ -57,16 +58,13 @@ public async Task Should_expose_native_message_id_headers_and_body_on_message_co public async Task Should_require_native_message_id_to_be_set() { var message = ServiceBusModelFactory.ServiceBusReceivedMessage(); - - var result = await ProcessMessage( - message: message - ); - + var result = await ProcessMessage(message: message); using (Assert.EnterMultipleScope()) { Assert.False(result.OnMessageWasCalled, "OnMessage should not be called"); Assert.False(result.OnErrorWasCalled, "OnError should not be called"); Assert.True(result.MessageActions.WasDeadLettered, "Missing native message id should result in message being dead lettered"); + Assert.That(result.Logs, Has.Some.Matches(l => l.LogLevel == LogLevel.Error && l.Message.Contains("MessageId is required")), "Should log error for missing MessageId"); } } @@ -130,17 +128,21 @@ public async Task Should_abandon_when_on_error_throws() Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned if onError throws"); Assert.IsFalse(result.MessageActions.WasDeadLettered, "Message should not be dead lettered"); + Assert.That(result.Logs, Has.Some.Matches(l => l.LogLevel == LogLevel.Warning && l.Message.Contains("Failed to execute onError")), "Should log warning when onError throws"); } } [Test] public async Task Should_dlq_message_if_requested() { + var expectedDlqReason = "some reason"; + var expectedDlqDescription = "some description"; + var result = await ProcessMessage( onMessage: (_, _) => throw new Exception("simulated exception"), onError: (errorContext, _) => { - errorContext.TransportTransaction.Set(new DeadLetterRequest("reason", "description", new Dictionary { { "MyProperty", "MyValue" } })); + errorContext.TransportTransaction.Set(new DeadLetterRequest(expectedDlqReason, expectedDlqDescription, new Dictionary { { "MyProperty", "MyValue" } })); return Task.FromResult(ErrorHandleResult.Handled); }); @@ -149,9 +151,11 @@ public async Task Should_dlq_message_if_requested() Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); Assert.IsFalse(result.MessageActions.WasAbandoned, "Message should not be abandoned"); Assert.IsTrue(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterReason, "reason"); - Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterErrorDescription, "description"); + Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterReason, expectedDlqReason); + Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterErrorDescription, expectedDlqDescription); Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterProperties?["MyProperty"], "MyValue"); + Assert.That(result.Logs, Has.Some.Matches(l => l.LogLevel == LogLevel.Error && l.Message.Contains(expectedDlqReason)), "Should log dlq reason"); + Assert.That(result.Logs, Has.Some.Matches(l => l.LogLevel == LogLevel.Error && l.Message.Contains(expectedDlqDescription)), "Should log dlq description"); } } @@ -206,7 +210,8 @@ async Task ProcessMessage( onMessage ??= (_, _) => Task.CompletedTask; onError ??= (_, _) => Task.FromResult(ErrorHandleResult.RetryRequired); - var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver(), NullLogger.Instance); + var testLogger = new TestLogger(); + var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver(), testLogger); MessageContext? capturedMessageContext = null; ErrorContext? capturedErrorContext = null; var messageActions = new TestableMessageActions(); @@ -226,18 +231,41 @@ await processor.Initialize(PushRuntimeSettings.Default, Assert.DoesNotThrowAsync(async () => await processor.Process(message, messageActions, cancellationToken)); - return new ProcessingResult(messageActions, capturedMessageContext, capturedErrorContext); + return new ProcessingResult(messageActions, capturedMessageContext, capturedErrorContext, testLogger.Logs); } - class ProcessingResult(TestableMessageActions messageActions, MessageContext? messageContext, ErrorContext? errorContext) + class ProcessingResult { - public TestableMessageActions MessageActions { get; } = messageActions; - public MessageContext? MessageContext { get; } = messageContext; - public ErrorContext? ErrorContext { get; } = errorContext; + public ProcessingResult(TestableMessageActions messageActions, MessageContext? messageContext, ErrorContext? errorContext, IReadOnlyList logs) + { + MessageActions = messageActions; + MessageContext = messageContext; + ErrorContext = errorContext; + Logs = logs; + } + + public TestableMessageActions MessageActions { get; } + public MessageContext? MessageContext { get; } + public ErrorContext? ErrorContext { get; } + public IReadOnlyList Logs { get; } public bool OnMessageWasCalled => MessageContext != null; public bool OnErrorWasCalled => ErrorContext != null; } + class TestLogger : ILogger + { + public List Logs { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => NullLogger.Instance.BeginScope(state); + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Logs.Add(new LogEntry(logLevel, formatter(state, exception), exception)); + } + + record LogEntry(LogLevel LogLevel, string Message, Exception? Exception); + + class TestableMessageActions : ServiceBusMessageActions { public bool WasCompleted { get; private set; } From 450859a15c08cb2404c2ac30a3c03abc367a9304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 17:14:24 +0100 Subject: [PATCH 29/68] Cleanup --- src/Tests/MessageProcessorTests.cs | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 9b1d9e2..f0ac408 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -234,20 +234,12 @@ await processor.Initialize(PushRuntimeSettings.Default, return new ProcessingResult(messageActions, capturedMessageContext, capturedErrorContext, testLogger.Logs); } - class ProcessingResult + class ProcessingResult(TestableMessageActions messageActions, MessageContext? messageContext, ErrorContext? errorContext, IReadOnlyList logs) { - public ProcessingResult(TestableMessageActions messageActions, MessageContext? messageContext, ErrorContext? errorContext, IReadOnlyList logs) - { - MessageActions = messageActions; - MessageContext = messageContext; - ErrorContext = errorContext; - Logs = logs; - } - - public TestableMessageActions MessageActions { get; } - public MessageContext? MessageContext { get; } - public ErrorContext? ErrorContext { get; } - public IReadOnlyList Logs { get; } + public TestableMessageActions MessageActions { get; } = messageActions; + public MessageContext? MessageContext { get; } = messageContext; + public ErrorContext? ErrorContext { get; } = errorContext; + public IReadOnlyList Logs { get; } = logs; public bool OnMessageWasCalled => MessageContext != null; public bool OnErrorWasCalled => ErrorContext != null; } @@ -265,7 +257,6 @@ class TestLogger : ILogger record LogEntry(LogLevel LogLevel, string Message, Exception? Exception); - class TestableMessageActions : ServiceBusMessageActions { public bool WasCompleted { get; private set; } From 3d7741c2a32e0ff9cf394f4dc124240aad1de96a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 17:18:34 +0100 Subject: [PATCH 30/68] Clarify todo --- .../PipelineInvokingMessageProcessor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 8d73dc4..50f4a44 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -38,7 +38,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc var body = message.Body ?? BinaryData.FromBytes(ReadOnlyMemory.Empty); - //TODO: Should we get the headers up here as well + //TODO: Should we get the headers up here as well, one thing to note is that since onMessage can mutate the headers we need to clone them when calling on error var contextBag = new ContextBag(); From b52eee653418290ea0206ba6efc85a48736eb413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 18:00:47 +0100 Subject: [PATCH 31/68] Use the MS FakeLogger --- src/Tests/MessageProcessorTests.cs | 45 +++++++------------ .../NServiceBus.AzureFunctions.Tests.csproj | 1 + 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index f0ac408..d53be13 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -3,8 +3,7 @@ namespace NServiceBus.AzureFunctions.Tests; using Azure.Messaging.ServiceBus; using AzureServiceBus; using Microsoft.Azure.Functions.Worker; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using NUnit.Framework; using Transport; using NServiceBus; @@ -64,7 +63,8 @@ public async Task Should_require_native_message_id_to_be_set() Assert.False(result.OnMessageWasCalled, "OnMessage should not be called"); Assert.False(result.OnErrorWasCalled, "OnError should not be called"); Assert.True(result.MessageActions.WasDeadLettered, "Missing native message id should result in message being dead lettered"); - Assert.That(result.Logs, Has.Some.Matches(l => l.LogLevel == LogLevel.Error && l.Message.Contains("MessageId is required")), "Should log error for missing MessageId"); + Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Error, "Invalid native message id should be logged as error"); + Assert.True(result.LogCollector.LatestRecord.Message.Contains("MessageId is required"), "Should log error for missing MessageId"); } } @@ -128,7 +128,8 @@ public async Task Should_abandon_when_on_error_throws() Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned if onError throws"); Assert.IsFalse(result.MessageActions.WasDeadLettered, "Message should not be dead lettered"); - Assert.That(result.Logs, Has.Some.Matches(l => l.LogLevel == LogLevel.Warning && l.Message.Contains("Failed to execute onError")), "Should log warning when onError throws"); + Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Warning, "Failure in onError should be logged as warning"); + Assert.True(result.LogCollector.LatestRecord.Message.Contains("Failed to execute onError"), "Should log warning when onError throws"); } } @@ -154,8 +155,9 @@ public async Task Should_dlq_message_if_requested() Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterReason, expectedDlqReason); Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterErrorDescription, expectedDlqDescription); Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterProperties?["MyProperty"], "MyValue"); - Assert.That(result.Logs, Has.Some.Matches(l => l.LogLevel == LogLevel.Error && l.Message.Contains(expectedDlqReason)), "Should log dlq reason"); - Assert.That(result.Logs, Has.Some.Matches(l => l.LogLevel == LogLevel.Error && l.Message.Contains(expectedDlqDescription)), "Should log dlq description"); + Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Error, "DLQ requests should be logged as error"); + Assert.True(result.LogCollector.LatestRecord.Message.Contains(expectedDlqReason), "Should log DLQ reason"); + Assert.True(result.LogCollector.LatestRecord.Message.Contains(expectedDlqDescription), "Should log DLQ description"); } } @@ -210,8 +212,8 @@ async Task ProcessMessage( onMessage ??= (_, _) => Task.CompletedTask; onError ??= (_, _) => Task.FromResult(ErrorHandleResult.RetryRequired); - var testLogger = new TestLogger(); - var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver(), testLogger); + var fakeLogger = new FakeLogger(); + var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver(), fakeLogger); MessageContext? capturedMessageContext = null; ErrorContext? capturedErrorContext = null; var messageActions = new TestableMessageActions(); @@ -231,38 +233,21 @@ await processor.Initialize(PushRuntimeSettings.Default, Assert.DoesNotThrowAsync(async () => await processor.Process(message, messageActions, cancellationToken)); - return new ProcessingResult(messageActions, capturedMessageContext, capturedErrorContext, testLogger.Logs); + return new ProcessingResult(messageActions, capturedMessageContext, capturedErrorContext, fakeLogger.Collector); } - class ProcessingResult(TestableMessageActions messageActions, MessageContext? messageContext, ErrorContext? errorContext, IReadOnlyList logs) + record ProcessingResult(TestableMessageActions MessageActions, MessageContext? MessageContext, ErrorContext? ErrorContext, FakeLogCollector LogCollector) { - public TestableMessageActions MessageActions { get; } = messageActions; - public MessageContext? MessageContext { get; } = messageContext; - public ErrorContext? ErrorContext { get; } = errorContext; - public IReadOnlyList Logs { get; } = logs; public bool OnMessageWasCalled => MessageContext != null; public bool OnErrorWasCalled => ErrorContext != null; } - class TestLogger : ILogger - { - public List Logs { get; } = []; - - public IDisposable BeginScope(TState state) where TState : notnull => NullLogger.Instance.BeginScope(state); - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Logs.Add(new LogEntry(logLevel, formatter(state, exception), exception)); - } - - record LogEntry(LogLevel LogLevel, string Message, Exception? Exception); - class TestableMessageActions : ServiceBusMessageActions { public bool WasCompleted { get; private set; } public bool WasAbandoned { get; private set; } public bool WasDeadLettered => DeadLetterDetails is not null; - public DeadLetterDetails? DeadLetterDetails { get; private set; } + public DeadLetterCallDetails? DeadLetterDetails { get; private set; } public override Task CompleteMessageAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = new CancellationToken()) { @@ -281,9 +266,9 @@ class TestableMessageActions : ServiceBusMessageActions DeadLetterDetails = new(deadLetterReason, deadLetterErrorDescription, propertiesToModify); return Task.CompletedTask; } - } - record DeadLetterDetails(string? DeadLetterReason, string? DeadLetterErrorDescription, Dictionary? DeadLetterProperties); + public record DeadLetterCallDetails(string? DeadLetterReason, string? DeadLetterErrorDescription, Dictionary? DeadLetterProperties); + } class FakeBaseReceiver : IMessageReceiver { diff --git a/src/Tests/NServiceBus.AzureFunctions.Tests.csproj b/src/Tests/NServiceBus.AzureFunctions.Tests.csproj index b28b42c..223911a 100644 --- a/src/Tests/NServiceBus.AzureFunctions.Tests.csproj +++ b/src/Tests/NServiceBus.AzureFunctions.Tests.csproj @@ -13,6 +13,7 @@ + From 401f7206aa9d988f7a8101abfa5008800c314de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 18:43:26 +0100 Subject: [PATCH 32/68] More details --- .../PipelineInvokingMessageProcessor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 50f4a44..aa6d2ae 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -77,7 +77,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc } catch (Exception ex) { - //TODO: The transport has a circuit breaker for repeated failures, should we go with something similar? + //TODO: The transport has a circuit breaker for repeated failures, should we go with something similar? we could do a LRU cache and then dead letter after X retries await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); logger.LogWarning(ex, "Failed to execute onError"); return; From 83c569a98eb1a289cd285b2c6481381c28b34df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Sun, 22 Mar 2026 18:49:16 +0100 Subject: [PATCH 33/68] Refine dead letter logging message for clarity --- .../PipelineInvokingMessageProcessor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index aa6d2ae..6df85c1 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -87,7 +87,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { await messageActions.DeadLetterMessageAsync(message, deadLetterRequest.PropertiesToModify, deadLetterRequest.DeadLetterReason, deadLetterRequest.DeadLetterErrorDescription, cancellationToken: CancellationToken.None).ConfigureAwait(false); - logger.LogError($"Message {nativeMessageId} was dead lettered due to {deadLetterRequest.DeadLetterReason}: {deadLetterRequest.DeadLetterErrorDescription}"); + logger.LogError($"User requested {nativeMessageId} to be dead lettered due to {deadLetterRequest.DeadLetterReason}: {deadLetterRequest.DeadLetterErrorDescription}"); return; } From 6e81c9234c27f0c2434b3b4021e359a002ed84e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Mon, 23 Mar 2026 21:35:31 +0100 Subject: [PATCH 34/68] Log when processing and on error cancelled --- .../PipelineInvokingMessageProcessor.cs | 8 +++--- src/Tests/MessageProcessorTests.cs | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 6df85c1..2f2ba32 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -55,9 +55,9 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc azureServiceBusTransportTransaction.Commit(); await messageActions.CompleteMessageAsync(message, CancellationToken.None).ConfigureAwait(false); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { - //TODO: Should we log? + logger.LogDebug(ex, "Message processing canceled."); await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); } catch (Exception exception) @@ -69,9 +69,9 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { errorHandleResult = await onError!.Invoke(errorContext, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { - //TODO: Should we log? + logger.LogDebug(ex, "OnError canceled."); await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); return; } diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index d53be13..8e85dff 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -194,6 +194,32 @@ public async Task Should_abandon_when_token_is_cancelled_and_not_invoke_onerror( Assert.IsFalse(result.OnErrorWasCalled, "OnError should not be called"); Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned"); + Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Debug, "Cancellation should be logged as debug"); + Assert.True(result.LogCollector.LatestRecord.Message.Contains("Message processing canceled"), "Should log debug when processing canceled"); + Assert.IsInstanceOf(result.LogCollector.LatestRecord.Exception); + } + } + + [Test] + public async Task Should_abandon_on_error_when_token_is_cancelled() + { + var result = await ProcessMessage( + onMessage: (_, _) => throw new Exception("simulated exception"), + onError: (_, ct) => + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(ErrorHandleResult.Handled); + }, + cancellationToken: new CancellationToken(true) + ); + + using (Assert.EnterMultipleScope()) + { + Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); + Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned"); + Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Debug, "Cancellation should be logged as debug"); + Assert.True(result.LogCollector.LatestRecord.Message.Contains("OnError canceled"), "Should log debug when on error canceled"); + Assert.IsInstanceOf(result.LogCollector.LatestRecord.Exception); } } From 06acb48987188de945c6b7d70ef4fc94b58ac112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 24 Mar 2026 06:39:46 +0100 Subject: [PATCH 35/68] Add test to ensure header mutations are not propagated from onMessage to onError --- src/Tests/MessageProcessorTests.cs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 8e85dff..b40908d 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -223,6 +223,36 @@ public async Task Should_abandon_on_error_when_token_is_cancelled() } } + [Test] + public async Task Should_not_propagate_header_mutations_from_on_message_to_on_error() + { + var originalHeaderKey = "original-header"; + var originalHeaderValue = "original-value"; + var addedHeaderKey = "added-header"; + + var message = ServiceBusModelFactory.ServiceBusReceivedMessage( + messageId: Guid.NewGuid().ToString(), + properties: new Dictionary { { originalHeaderKey, originalHeaderValue } } + ); + + var result = await ProcessMessage( + message: message, + onMessage: (msgContext, _) => + { + msgContext.Headers[addedHeaderKey] = "some-value"; + msgContext.Headers[originalHeaderKey] = "some-other-value"; + throw new Exception("force error"); + } + ); + + var headers = result.ErrorContext!.Message.Headers; + using (Assert.EnterMultipleScope()) + { + Assert.IsTrue(headers.ContainsKey(originalHeaderKey), "Original header should still exist in onError"); + Assert.AreEqual(originalHeaderValue, headers[originalHeaderKey], "Original header value should be preserved in onError"); + Assert.IsFalse(headers.ContainsKey(addedHeaderKey), "Added header should NOT be present in onError"); + } + } //TODO: Tests to add // ShouldDLQHeaderExtractionFails? // ShouldNotAllowHeaderOrBodyMutationsAcrossOnMessageAndOnError From c3e3927c43e3ae640c5f7f9b40fd2d2102fe12aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 24 Mar 2026 06:40:20 +0100 Subject: [PATCH 36/68] Cleanup todos --- src/Tests/MessageProcessorTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index b40908d..634d311 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -253,10 +253,9 @@ public async Task Should_not_propagate_header_mutations_from_on_message_to_on_er Assert.IsFalse(headers.ContainsKey(addedHeaderKey), "Added header should NOT be present in onError"); } } + //TODO: Tests to add // ShouldDLQHeaderExtractionFails? - // ShouldNotAllowHeaderOrBodyMutationsAcrossOnMessageAndOnError - // Should_expose_a_unique_transport_transaction_for_onmessage_and_onerror? async Task ProcessMessage( ServiceBusReceivedMessage? message = null, From b1b9b1c387a96732667bc2cbc963e59e8af2c89e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 24 Mar 2026 07:11:30 +0100 Subject: [PATCH 37/68] DLQ if header extraction fails --- .../PipelineInvokingMessageProcessor.cs | 44 +++++++++++++++---- src/Tests/MessageProcessorTests.cs | 21 +++++++-- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 2f2ba32..7ec6e72 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -10,13 +10,24 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus; using NServiceBus.Transport; using NServiceBus.Transport.AzureServiceBus; -class PipelineInvokingMessageProcessor(IMessageReceiver baseTransportReceiver, ILogger logger) : IMessageReceiver +class PipelineInvokingMessageProcessor : IMessageReceiver { + public PipelineInvokingMessageProcessor(IMessageReceiver baseTransportReceiver, + ILogger logger, + Func>? headerExtractor = null) + { + this.baseTransportReceiver = baseTransportReceiver; + this.logger = logger; + + extractHeaders = headerExtractor ?? GetNServiceBusHeaders; + } + public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnError onError, CancellationToken cancellationToken = default) { this.onMessage = onMessage; this.onError = onError; + return baseTransportReceiver.Initialize(limitations, (_, __) => Task.CompletedTask, (_, __) => Task.FromResult(ErrorHandleResult.Handled), @@ -36,9 +47,24 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc return; } - var body = message.Body ?? BinaryData.FromBytes(ReadOnlyMemory.Empty); + Dictionary headers; + try + { + headers = extractHeaders(message); + } + catch (Exception ex) + { + const string deadLetterReason = "Failed to extract headers from message."; + + await messageActions.DeadLetterMessageAsync(message, + deadLetterReason: deadLetterReason, + deadLetterErrorDescription: ex.ToString(), cancellationToken: CancellationToken.None).ConfigureAwait(false); + + logger.LogError(deadLetterReason); + return; + } - //TODO: Should we get the headers up here as well, one thing to note is that since onMessage can mutate the headers we need to clone them when calling on error + var body = message.Body ?? BinaryData.FromBytes(ReadOnlyMemory.Empty); var contextBag = new ContextBag(); @@ -48,7 +74,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc try { using var azureServiceBusTransportTransaction = new AzureServiceBusTransportTransaction(); - var messageContext = CreateMessageContext(message, nativeMessageId, body, azureServiceBusTransportTransaction.TransportTransaction, contextBag); + var messageContext = new MessageContext(nativeMessageId, headers, body, azureServiceBusTransportTransaction.TransportTransaction, ReceiveAddress, contextBag); await onMessage!(messageContext, cancellationToken).ConfigureAwait(false); @@ -106,11 +132,7 @@ ErrorContext CreateErrorContext(ServiceBusReceivedMessage message, Exception exc BinaryData body, TransportTransaction transportTransaction, ContextBag contextBag) => new(exception, GetNServiceBusHeaders(message), messageId, body, transportTransaction, message.DeliveryCount, ReceiveAddress, contextBag); - MessageContext CreateMessageContext(ServiceBusReceivedMessage message, string messageId, BinaryData body, - TransportTransaction transportTransaction, ContextBag contextBag) => - new(messageId, GetNServiceBusHeaders(message), body, transportTransaction, ReceiveAddress, contextBag); - - static Dictionary GetNServiceBusHeaders(ServiceBusReceivedMessage message) + Dictionary GetNServiceBusHeaders(ServiceBusReceivedMessage message) { var headers = new Dictionary(message.ApplicationProperties.Count); @@ -150,4 +172,8 @@ MessageContext CreateMessageContext(ServiceBusReceivedMessage message, string me OnMessage? onMessage; OnError? onError; + + readonly IMessageReceiver baseTransportReceiver; + readonly ILogger logger; + readonly Func> extractHeaders; } \ No newline at end of file diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 634d311..385dcb3 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -254,13 +254,28 @@ public async Task Should_not_propagate_header_mutations_from_on_message_to_on_er } } - //TODO: Tests to add - // ShouldDLQHeaderExtractionFails? + [Test] + public async Task Should_deadletter_if_header_extraction_fails() + { + var result = await ProcessMessage(headerExtractor: _ => throw new Exception("Simulated header extraction failure")); + + using (Assert.EnterMultipleScope()) + { + Assert.False(result.OnMessageWasCalled, "OnMessage should not be called if header extraction fails"); + Assert.False(result.OnErrorWasCalled, "OnError should not be called if header extraction fails"); + Assert.True(result.MessageActions.WasDeadLettered, "Message should be deadlettered if header extraction fails"); + Assert.True(result.MessageActions.DeadLetterDetails?.DeadLetterReason?.Contains("Failed to extract headers")); + Assert.True(result.MessageActions.DeadLetterDetails?.DeadLetterErrorDescription?.Contains("Simulated header extraction failure")); + Assert.AreEqual(Microsoft.Extensions.Logging.LogLevel.Error, result.LogCollector.LatestRecord.Level, "Header extraction failure should be logged as error"); + Assert.True(result.LogCollector.LatestRecord.Message.Contains("Failed to extract headers"), "Should log header extraction failure"); + } + } async Task ProcessMessage( ServiceBusReceivedMessage? message = null, Func? onMessage = null, Func>? onError = null, + Func>? headerExtractor = null, CancellationToken cancellationToken = default) { message ??= ServiceBusModelFactory.ServiceBusReceivedMessage(messageId: Guid.NewGuid().ToString()); @@ -268,7 +283,7 @@ async Task ProcessMessage( onError ??= (_, _) => Task.FromResult(ErrorHandleResult.RetryRequired); var fakeLogger = new FakeLogger(); - var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver(), fakeLogger); + var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver(), fakeLogger, headerExtractor); MessageContext? capturedMessageContext = null; ErrorContext? capturedErrorContext = null; var messageActions = new TestableMessageActions(); From f19881389d82856f6ceb232887d4b01a0d8f635e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 24 Mar 2026 07:14:53 +0100 Subject: [PATCH 38/68] Clone header --- .../PipelineInvokingMessageProcessor.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 7ec6e72..2341956 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -74,7 +74,9 @@ await messageActions.DeadLetterMessageAsync(message, try { using var azureServiceBusTransportTransaction = new AzureServiceBusTransportTransaction(); - var messageContext = new MessageContext(nativeMessageId, headers, body, azureServiceBusTransportTransaction.TransportTransaction, ReceiveAddress, contextBag); + + // we need to clone the headers since the core pipeline might mutate them + var messageContext = new MessageContext(nativeMessageId, new Dictionary(headers), body, azureServiceBusTransportTransaction.TransportTransaction, ReceiveAddress, contextBag); await onMessage!(messageContext, cancellationToken).ConfigureAwait(false); @@ -89,7 +91,7 @@ await messageActions.DeadLetterMessageAsync(message, catch (Exception exception) { using var azureServiceBusTransportTransaction = new AzureServiceBusTransportTransaction(); - var errorContext = CreateErrorContext(message, exception, nativeMessageId, body, azureServiceBusTransportTransaction.TransportTransaction, contextBag); + var errorContext = new ErrorContext(exception, headers, nativeMessageId, body, azureServiceBusTransportTransaction.TransportTransaction, message.DeliveryCount, ReceiveAddress, contextBag); ErrorHandleResult errorHandleResult; try { @@ -128,10 +130,6 @@ await messageActions.DeadLetterMessageAsync(message, } } - ErrorContext CreateErrorContext(ServiceBusReceivedMessage message, Exception exception, string messageId, - BinaryData body, TransportTransaction transportTransaction, ContextBag contextBag) => - new(exception, GetNServiceBusHeaders(message), messageId, body, transportTransaction, message.DeliveryCount, ReceiveAddress, contextBag); - Dictionary GetNServiceBusHeaders(ServiceBusReceivedMessage message) { var headers = new Dictionary(message.ApplicationProperties.Count); From 029be0e2877515da515bba355e8981f060314f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 24 Mar 2026 07:15:47 +0100 Subject: [PATCH 39/68] Include context creating in try catch --- .../PipelineInvokingMessageProcessor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 2341956..2f42437 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -91,10 +91,10 @@ await messageActions.DeadLetterMessageAsync(message, catch (Exception exception) { using var azureServiceBusTransportTransaction = new AzureServiceBusTransportTransaction(); - var errorContext = new ErrorContext(exception, headers, nativeMessageId, body, azureServiceBusTransportTransaction.TransportTransaction, message.DeliveryCount, ReceiveAddress, contextBag); ErrorHandleResult errorHandleResult; try { + var errorContext = new ErrorContext(exception, headers, nativeMessageId, body, azureServiceBusTransportTransaction.TransportTransaction, message.DeliveryCount, ReceiveAddress, contextBag); errorHandleResult = await onError!.Invoke(errorContext, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) @@ -111,7 +111,7 @@ await messageActions.DeadLetterMessageAsync(message, return; } - if (errorContext.TransportTransaction.TryGet(out var deadLetterRequest)) + if (azureServiceBusTransportTransaction.TransportTransaction.TryGet(out var deadLetterRequest)) { await messageActions.DeadLetterMessageAsync(message, deadLetterRequest.PropertiesToModify, deadLetterRequest.DeadLetterReason, deadLetterRequest.DeadLetterErrorDescription, cancellationToken: CancellationToken.None).ConfigureAwait(false); From e63af944102dba735d0ebf0e6829f999085a2c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 24 Mar 2026 13:32:04 +0100 Subject: [PATCH 40/68] Dead letter message if on error fails with non-transient exception --- .../PipelineInvokingMessageProcessor.cs | 51 +++++++++------ src/Tests/MessageProcessorTests.cs | 65 ++++++++++++++----- 2 files changed, 79 insertions(+), 37 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 2f42437..e0ff4d1 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -36,31 +36,17 @@ public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnE public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, CancellationToken cancellationToken = default) { - var nativeMessageId = message.MessageId; - if (string.IsNullOrEmpty(nativeMessageId)) - { - const string deadLetterErrorDescription = "Azure Service Bus MessageId is required, but was not found. Ensure to assign MessageId to all Service Bus messages."; - - await messageActions.DeadLetterMessageAsync(message, deadLetterReason: "MessageId not set on message", deadLetterErrorDescription: deadLetterErrorDescription, cancellationToken: CancellationToken.None).ConfigureAwait(false); - - logger.LogError(deadLetterErrorDescription); - return; - } - + string nativeMessageId; Dictionary headers; + try { + nativeMessageId = GetNativeMessageId(message); headers = extractHeaders(message); } catch (Exception ex) { - const string deadLetterReason = "Failed to extract headers from message."; - - await messageActions.DeadLetterMessageAsync(message, - deadLetterReason: deadLetterReason, - deadLetterErrorDescription: ex.ToString(), cancellationToken: CancellationToken.None).ConfigureAwait(false); - - logger.LogError(deadLetterReason); + await DeadLetterMessage(messageActions, message, ex, CancellationToken.None).ConfigureAwait(false); return; } @@ -103,11 +89,15 @@ await messageActions.DeadLetterMessageAsync(message, await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); return; } - catch (Exception ex) + catch (ServiceBusException ex) when (ex.IsTransient || ex.Reason == ServiceBusFailureReason.MessageLockLost) { - //TODO: The transport has a circuit breaker for repeated failures, should we go with something similar? we could do a LRU cache and then dead letter after X retries + logger.LogWarning(ex, "OnError failed due to transient exception."); await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); - logger.LogWarning(ex, "Failed to execute onError"); + return; + } + catch (Exception ex) + { + await DeadLetterMessage(messageActions, message, ex, CancellationToken.None).ConfigureAwait(false); return; } @@ -157,6 +147,25 @@ await messageActions.DeadLetterMessageAsync(message, return headers; } + Task DeadLetterMessage(ServiceBusMessageActions messageActions, ServiceBusReceivedMessage message, Exception exception, CancellationToken cancellationToken) + { + logger.LogError(exception, "Message dead lettered due to exception"); + + return messageActions.DeadLetterMessageAsync(message, + deadLetterReason: exception.GetType().FullName, + deadLetterErrorDescription: exception.StackTrace, cancellationToken: cancellationToken); + } + + static string GetNativeMessageId(ServiceBusReceivedMessage message) + { + if (string.IsNullOrEmpty(message.MessageId)) + { + throw new Exception("Azure Service Bus MessageId is required, but was not found. Ensure to assign MessageId to all Service Bus messages."); + } + + return message.MessageId; + } + public Task StartReceive(CancellationToken cancellationToken = default) => Task.CompletedTask; // No-op because the rate at which Azure Functions pushes messages to the pipeline can't be controlled. diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 385dcb3..39ba053 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -62,9 +62,7 @@ public async Task Should_require_native_message_id_to_be_set() { Assert.False(result.OnMessageWasCalled, "OnMessage should not be called"); Assert.False(result.OnErrorWasCalled, "OnError should not be called"); - Assert.True(result.MessageActions.WasDeadLettered, "Missing native message id should result in message being dead lettered"); - Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Error, "Invalid native message id should be logged as error"); - Assert.True(result.LogCollector.LatestRecord.Message.Contains("MessageId is required"), "Should log error for missing MessageId"); + Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); } } @@ -115,21 +113,48 @@ public async Task Should_complete_when_on_message_fails_and_failure_is_marked_as } [Test] - public async Task Should_abandon_when_on_error_throws() + public async Task Should_abandon_when_on_error_throws_transient_service_bus_exception() { var result = await ProcessMessage( onMessage: (_, _) => throw new Exception("simulated exception"), - onError: (_, _) => throw new Exception("simulated onError failure")); + onError: (_, _) => throw new ServiceBusException("simulated transient exception", ServiceBusFailureReason.ServiceBusy)); //ServiceBusy is transient + + using (Assert.EnterMultipleScope()) + { + Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); + Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned if onError throws"); + Assert.IsFalse(result.MessageActions.WasDeadLettered, "Message should not be dead lettered"); + } + } + + [Test] + public async Task Should_abandon_when_on_error_throws_lock_lost_service_bus_exception() + { + var result = await ProcessMessage( + onMessage: (_, _) => throw new Exception("simulated exception"), + onError: (_, _) => throw new ServiceBusException("simulated lock lost exception", ServiceBusFailureReason.MessageLockLost)); using (Assert.EnterMultipleScope()) { - Assert.IsTrue(result.OnMessageWasCalled, "OnMessage should be called"); - Assert.IsTrue(result.OnErrorWasCalled, "OnError should be called"); Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned if onError throws"); Assert.IsFalse(result.MessageActions.WasDeadLettered, "Message should not be dead lettered"); - Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Warning, "Failure in onError should be logged as warning"); - Assert.True(result.LogCollector.LatestRecord.Message.Contains("Failed to execute onError"), "Should log warning when onError throws"); + } + } + + [Test] + public async Task Should_dlq_when_on_error_throws_non_transient_exception() + { + var exception = new Exception("simulated exception in on error"); + var result = await ProcessMessage( + onMessage: (_, _) => throw new Exception("simulated exception"), + onError: (_, _) => throw exception); + + using (Assert.EnterMultipleScope()) + { + Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); + Assert.IsFalse(result.MessageActions.WasAbandoned, "Message should not be abandoned if onError throws"); + AssertExceptionWasDeadLettered(result, exception); } } @@ -255,22 +280,30 @@ public async Task Should_not_propagate_header_mutations_from_on_message_to_on_er } [Test] - public async Task Should_deadletter_if_header_extraction_fails() + public async Task Should_dead_letter_if_header_extraction_fails() { - var result = await ProcessMessage(headerExtractor: _ => throw new Exception("Simulated header extraction failure")); + var exception = new Exception("simulated exception"); + var result = await ProcessMessage(headerExtractor: _ => throw exception); using (Assert.EnterMultipleScope()) { Assert.False(result.OnMessageWasCalled, "OnMessage should not be called if header extraction fails"); Assert.False(result.OnErrorWasCalled, "OnError should not be called if header extraction fails"); - Assert.True(result.MessageActions.WasDeadLettered, "Message should be deadlettered if header extraction fails"); - Assert.True(result.MessageActions.DeadLetterDetails?.DeadLetterReason?.Contains("Failed to extract headers")); - Assert.True(result.MessageActions.DeadLetterDetails?.DeadLetterErrorDescription?.Contains("Simulated header extraction failure")); - Assert.AreEqual(Microsoft.Extensions.Logging.LogLevel.Error, result.LogCollector.LatestRecord.Level, "Header extraction failure should be logged as error"); - Assert.True(result.LogCollector.LatestRecord.Message.Contains("Failed to extract headers"), "Should log header extraction failure"); + Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); } } + void AssertExceptionWasDeadLettered(ProcessingResult result, Exception exception) + { + Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); + + // Make sure we follow microsoft guidance - https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-dead-letter-queues#application-level-dead-lettering + Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterReason, exception.GetType().FullName); + Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterErrorDescription, exception.StackTrace); + Assert.AreEqual(Microsoft.Extensions.Logging.LogLevel.Error, result.LogCollector.LatestRecord.Level, "Dead lettering be logged as error"); + Assert.AreEqual(result.LogCollector.LatestRecord.Exception, exception); + } + async Task ProcessMessage( ServiceBusReceivedMessage? message = null, Func? onMessage = null, From 111a98e5895b2db6449c4c88f1f1c2b3cc6d8d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 24 Mar 2026 13:53:48 +0100 Subject: [PATCH 41/68] Support requesting DLQ from recoverability policy --- .../Handlers/SubmitOrderHandler.cs | 6 ++++-- src/IntegrationTest.Sales/SalesEndpoint.cs | 4 ++++ .../DeadLetterMessage.cs | 19 +++++++++++++++++++ .../PipelineInvokingMessageProcessor.cs | 3 +-- src/Tests/MessageProcessorTests.cs | 2 +- 5 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs diff --git a/src/IntegrationTest.Sales/Handlers/SubmitOrderHandler.cs b/src/IntegrationTest.Sales/Handlers/SubmitOrderHandler.cs index bc71a24..3b14532 100644 --- a/src/IntegrationTest.Sales/Handlers/SubmitOrderHandler.cs +++ b/src/IntegrationTest.Sales/Handlers/SubmitOrderHandler.cs @@ -8,8 +8,10 @@ public class SubmitOrderHandler(ILogger logger, MyComponent { public async Task Handle(SubmitOrder message, IMessageHandlerContext context) { - logger.LogWarning($"Handling {nameof(SubmitOrder)} in {nameof(SubmitOrderHandler)} with component for {component.EndpointName}"); +// logger.LogWarning($"Handling {nameof(SubmitOrder)} in {nameof(SubmitOrderHandler)} with component for {component.EndpointName}"); +// +// await context.Publish(new OrderSubmitted()).ConfigureAwait(false); - await context.Publish(new OrderSubmitted()).ConfigureAwait(false); + throw new Exception("Simulated exception"); } } \ No newline at end of file diff --git a/src/IntegrationTest.Sales/SalesEndpoint.cs b/src/IntegrationTest.Sales/SalesEndpoint.cs index a50ab47..3322873 100644 --- a/src/IntegrationTest.Sales/SalesEndpoint.cs +++ b/src/IntegrationTest.Sales/SalesEndpoint.cs @@ -4,6 +4,7 @@ namespace IntegrationTest.Sales; using IntegrationTest.Shared; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.DependencyInjection; +using NServiceBus.AzureFunctions.AzureServiceBus; // Cleanest pattern for single-function endpoints [NServiceBusFunction] @@ -23,5 +24,8 @@ public static void ConfigureSales(EndpointConfiguration configuration) configuration.RegisterComponents(services => services.AddSingleton(new MyComponent("Sales"))); configuration.AddHandler(); + + // Demo using the dead letter queue for failures + configuration.Recoverability().CustomPolicy((_, context) => new DeadLetterMessage(context.Exception)); } } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs new file mode 100644 index 0000000..147fdbe --- /dev/null +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs @@ -0,0 +1,19 @@ +namespace NServiceBus.AzureFunctions.AzureServiceBus; + +using Pipeline; +using Transport; + +public class DeadLetterMessage(string deadLetterReason, string deadLetterErrorDescription, Dictionary? propertiesToModify = null) : RecoverabilityAction +{ + public DeadLetterMessage(Exception exception) : this($"{exception.GetType().FullName!} - {exception.Message}", exception.StackTrace ?? exception.ToString(), null) + { + } + + public override IReadOnlyCollection GetRoutingContexts(IRecoverabilityActionContext context) + { + context.Extensions.Get().Set(new DeadLetterRequest(deadLetterReason, deadLetterErrorDescription, propertiesToModify)); + return []; + } + + public override ErrorHandleResult ErrorHandleResult => ErrorHandleResult.Handled; +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index e0ff4d1..7ef1c1c 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -54,7 +54,6 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc var contextBag = new ContextBag(); - // Azure Service Bus transport also makes the incoming message available. We can do the same narrow the gap contextBag.Set(message); try @@ -152,7 +151,7 @@ Task DeadLetterMessage(ServiceBusMessageActions messageActions, ServiceBusReceiv logger.LogError(exception, "Message dead lettered due to exception"); return messageActions.DeadLetterMessageAsync(message, - deadLetterReason: exception.GetType().FullName, + deadLetterReason: $"{exception.GetType().FullName} - {exception.Message}", deadLetterErrorDescription: exception.StackTrace, cancellationToken: cancellationToken); } diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 39ba053..33d95eb 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -298,7 +298,7 @@ void AssertExceptionWasDeadLettered(ProcessingResult result, Exception exception Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); // Make sure we follow microsoft guidance - https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-dead-letter-queues#application-level-dead-lettering - Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterReason, exception.GetType().FullName); + Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterReason, $"{exception.GetType().FullName!} - {exception.Message}"); Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterErrorDescription, exception.StackTrace); Assert.AreEqual(Microsoft.Extensions.Logging.LogLevel.Error, result.LogCollector.LatestRecord.Level, "Dead lettering be logged as error"); Assert.AreEqual(result.LogCollector.LatestRecord.Exception, exception); From 2bea8e1f59582356508719f8753c409528581258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 24 Mar 2026 13:58:12 +0100 Subject: [PATCH 42/68] Approve api --- ...provals.ApprovaAzureServiceBusComponentApi.approved.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt b/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt index b4a6c05..0697282 100644 --- a/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt +++ b/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt @@ -6,6 +6,13 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus public AzureServiceBusMessageProcessor(NServiceBus.AzureServiceBusServerlessTransport transport, string endpointName) { } public System.Threading.Tasks.Task Process(Azure.Messaging.ServiceBus.ServiceBusReceivedMessage message, Microsoft.Azure.Functions.Worker.ServiceBusMessageActions messageActions, Microsoft.Azure.Functions.Worker.FunctionContext functionContext, System.Threading.CancellationToken cancellationToken = default) { } } + public class DeadLetterMessage : NServiceBus.RecoverabilityAction + { + public DeadLetterMessage(System.Exception exception) { } + public DeadLetterMessage(string deadLetterReason, string deadLetterErrorDescription, System.Collections.Generic.Dictionary? propertiesToModify = null) { } + public override NServiceBus.Transport.ErrorHandleResult ErrorHandleResult { get; } + public override System.Collections.Generic.IReadOnlyCollection GetRoutingContexts(NServiceBus.Pipeline.IRecoverabilityActionContext context) { } + } } namespace NServiceBus { From f5ce9de279e3a9dbc453408ffc86088bc8436a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 24 Mar 2026 14:50:10 +0100 Subject: [PATCH 43/68] Revert test code --- src/IntegrationTest.Sales/Handlers/SubmitOrderHandler.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/IntegrationTest.Sales/Handlers/SubmitOrderHandler.cs b/src/IntegrationTest.Sales/Handlers/SubmitOrderHandler.cs index 3b14532..bc71a24 100644 --- a/src/IntegrationTest.Sales/Handlers/SubmitOrderHandler.cs +++ b/src/IntegrationTest.Sales/Handlers/SubmitOrderHandler.cs @@ -8,10 +8,8 @@ public class SubmitOrderHandler(ILogger logger, MyComponent { public async Task Handle(SubmitOrder message, IMessageHandlerContext context) { -// logger.LogWarning($"Handling {nameof(SubmitOrder)} in {nameof(SubmitOrderHandler)} with component for {component.EndpointName}"); -// -// await context.Publish(new OrderSubmitted()).ConfigureAwait(false); + logger.LogWarning($"Handling {nameof(SubmitOrder)} in {nameof(SubmitOrderHandler)} with component for {component.EndpointName}"); - throw new Exception("Simulated exception"); + await context.Publish(new OrderSubmitted()).ConfigureAwait(false); } } \ No newline at end of file From e864ed5435585599cab89cb49aa8b3345ea552d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 24 Mar 2026 16:08:05 +0100 Subject: [PATCH 44/68] Remove demo --- src/IntegrationTest.Sales/SalesEndpoint.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/IntegrationTest.Sales/SalesEndpoint.cs b/src/IntegrationTest.Sales/SalesEndpoint.cs index 3322873..252ef75 100644 --- a/src/IntegrationTest.Sales/SalesEndpoint.cs +++ b/src/IntegrationTest.Sales/SalesEndpoint.cs @@ -25,7 +25,7 @@ public static void ConfigureSales(EndpointConfiguration configuration) configuration.RegisterComponents(services => services.AddSingleton(new MyComponent("Sales"))); configuration.AddHandler(); - // Demo using the dead letter queue for failures + // Use the dead letter queue for failures configuration.Recoverability().CustomPolicy((_, context) => new DeadLetterMessage(context.Exception)); } } \ No newline at end of file From 7a2a31329c2fe323e0795f90fe2774d068910839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Wed, 25 Mar 2026 14:21:37 +0100 Subject: [PATCH 45/68] Truncate dlq message and reason to 1024 to make it less likely go over size --- src/IntegrationTest/Program.cs | 1 + .../DeadLetterMessage.cs | 14 +++--- .../DeadLetterRequest.cs | 27 ++++++++++- .../PipelineInvokingMessageProcessor.cs | 38 ++++++++-------- src/Tests/DeadLetterMessageTests.cs | 45 +++++++++++++++++++ src/Tests/MessageProcessorTests.cs | 16 +------ 6 files changed, 102 insertions(+), 39 deletions(-) create mode 100644 src/Tests/DeadLetterMessageTests.cs diff --git a/src/IntegrationTest/Program.cs b/src/IntegrationTest/Program.cs index 3fc5d33..623e8de 100644 --- a/src/IntegrationTest/Program.cs +++ b/src/IntegrationTest/Program.cs @@ -16,6 +16,7 @@ builder.Services.AddSingleton(new MyComponent("global")); builder.AddNServiceBusFunctions(); + builder.AddSendOnlyNServiceBusEndpoint("client", configuration => { configuration.RegisterComponents(services => services.AddSingleton(new MyComponent("client"))); diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs index 147fdbe..53ec5f6 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs @@ -3,17 +3,21 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus; using Pipeline; using Transport; -public class DeadLetterMessage(string deadLetterReason, string deadLetterErrorDescription, Dictionary? propertiesToModify = null) : RecoverabilityAction +public class DeadLetterMessage : RecoverabilityAction { - public DeadLetterMessage(Exception exception) : this($"{exception.GetType().FullName!} - {exception.Message}", exception.StackTrace ?? exception.ToString(), null) - { - } + public DeadLetterMessage(string deadLetterReason, string deadLetterErrorDescription, Dictionary? propertiesToModify = null) => + deadLetterRequest = new DeadLetterRequest(deadLetterReason, deadLetterErrorDescription, propertiesToModify); + + public DeadLetterMessage(Exception exception) => + deadLetterRequest = new DeadLetterRequest(exception); public override IReadOnlyCollection GetRoutingContexts(IRecoverabilityActionContext context) { - context.Extensions.Get().Set(new DeadLetterRequest(deadLetterReason, deadLetterErrorDescription, propertiesToModify)); + context.Extensions.Get().Set(deadLetterRequest); return []; } public override ErrorHandleResult ErrorHandleResult => ErrorHandleResult.Handled; + + readonly DeadLetterRequest deadLetterRequest; } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs index f3933a3..b598789 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs @@ -1,3 +1,28 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus; -record DeadLetterRequest(string DeadLetterReason, string DeadLetterErrorDescription, Dictionary? PropertiesToModify); \ No newline at end of file +class DeadLetterRequest +{ + public string DeadLetterReason { get; } + public string DeadLetterErrorDescription { get; } + public Dictionary? PropertiesToModify { get; } + + public DeadLetterRequest(string deadLetterReason, string deadLetterErrorDescription, Dictionary? propertiesToModify = null) + { + DeadLetterReason = Truncate(deadLetterReason, 1024); + DeadLetterErrorDescription = Truncate(deadLetterErrorDescription, 1024); + PropertiesToModify = propertiesToModify; + } + + public DeadLetterRequest(Exception exception) : this( + $"{exception.GetType().FullName} - {exception.Message}", + exception.StackTrace ?? "No stack trace available") + { + } + + static string Truncate(string value, int maxLength) => + string.IsNullOrEmpty(value) + ? value + : value.Length <= maxLength + ? value + : value[..maxLength]; +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 7ef1c1c..4a29dab 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -38,24 +38,25 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { string nativeMessageId; Dictionary headers; + BinaryData body; + var contextBag = new ContextBag(); try { nativeMessageId = GetNativeMessageId(message); headers = extractHeaders(message); + body = message.Body ?? BinaryData.FromBytes(ReadOnlyMemory.Empty); + + contextBag.Set(message); } catch (Exception ex) { - await DeadLetterMessage(messageActions, message, ex, CancellationToken.None).ConfigureAwait(false); + logger.LogError(ex, "Message dead lettered due to issues with extracting message metadata."); + + await DeadLetterMessage(messageActions, message, new DeadLetterRequest(ex), CancellationToken.None).ConfigureAwait(false); return; } - var body = message.Body ?? BinaryData.FromBytes(ReadOnlyMemory.Empty); - - var contextBag = new ContextBag(); - - contextBag.Set(message); - try { using var azureServiceBusTransportTransaction = new AzureServiceBusTransportTransaction(); @@ -96,15 +97,18 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc } catch (Exception ex) { - await DeadLetterMessage(messageActions, message, ex, CancellationToken.None).ConfigureAwait(false); + logger.LogError(exception, "Message dead lettered due to exception in OnError."); + + await DeadLetterMessage(messageActions, message, new DeadLetterRequest(ex), CancellationToken.None).ConfigureAwait(false); return; } if (azureServiceBusTransportTransaction.TransportTransaction.TryGet(out var deadLetterRequest)) { - await messageActions.DeadLetterMessageAsync(message, deadLetterRequest.PropertiesToModify, deadLetterRequest.DeadLetterReason, deadLetterRequest.DeadLetterErrorDescription, cancellationToken: CancellationToken.None).ConfigureAwait(false); - logger.LogError($"User requested {nativeMessageId} to be dead lettered due to {deadLetterRequest.DeadLetterReason}: {deadLetterRequest.DeadLetterErrorDescription}"); + + await DeadLetterMessage(messageActions, message, deadLetterRequest, CancellationToken.None).ConfigureAwait(false); + return; } @@ -146,14 +150,12 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc return headers; } - Task DeadLetterMessage(ServiceBusMessageActions messageActions, ServiceBusReceivedMessage message, Exception exception, CancellationToken cancellationToken) - { - logger.LogError(exception, "Message dead lettered due to exception"); - - return messageActions.DeadLetterMessageAsync(message, - deadLetterReason: $"{exception.GetType().FullName} - {exception.Message}", - deadLetterErrorDescription: exception.StackTrace, cancellationToken: cancellationToken); - } + Task DeadLetterMessage(ServiceBusMessageActions messageActions, ServiceBusReceivedMessage message, DeadLetterRequest request, CancellationToken cancellationToken) => + messageActions.DeadLetterMessageAsync(message, + request.PropertiesToModify, + request.DeadLetterReason, + request.DeadLetterErrorDescription, + cancellationToken); static string GetNativeMessageId(ServiceBusReceivedMessage message) { diff --git a/src/Tests/DeadLetterMessageTests.cs b/src/Tests/DeadLetterMessageTests.cs new file mode 100644 index 0000000..982d0b5 --- /dev/null +++ b/src/Tests/DeadLetterMessageTests.cs @@ -0,0 +1,45 @@ +namespace NServiceBus.AzureFunctions.Tests; + +using AzureServiceBus; +using NUnit.Framework; + +public class DeadLetterMessageTests +{ + [Test] + public void Should_full_control_over_dead_letter_parameters() + { + var reason = "reason"; + var description = "description"; + var properties = new Dictionary { { "SomeProperty", "SomeValue" } }; + var request = new DeadLetterRequest(reason, description, properties); + + Assert.AreEqual(reason, request.DeadLetterReason, "DeadLetterReason should be set correctly"); + Assert.AreEqual(description, request.DeadLetterErrorDescription, "DeadLetterErrorDescription should be set correctly"); + Assert.IsNotNull(request.PropertiesToModify, "PropertiesToModify should not be null"); + Assert.IsTrue(request.PropertiesToModify!.ContainsKey("SomeProperty"), "PropertiesToModify should contain 'SomeProperty'"); + Assert.AreEqual("SomeValue", request.PropertiesToModify["SomeProperty"], "PropertiesToModify['SomeProperty'] should be set correctly"); + } + + [Test] + public void Should_convert_exception_to_dead_letter_request() + { + var exception = new InvalidOperationException("Test exception"); + var request = new DeadLetterRequest(exception); + + // Make sure we follow microsoft guidance - https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-dead-letter-queues#application-level-dead-lettering + Assert.AreEqual("System.InvalidOperationException - Test exception", request.DeadLetterReason, "DeadLetterReason should reflect exception type and message"); + Assert.AreEqual(request.DeadLetterErrorDescription, "No stack trace available", "DeadLetterErrorDescription should contain stack trace"); + Assert.IsNull(request.PropertiesToModify, "PropertiesToModify should be null for exception-based dead lettering"); + } + + [Test] + public void Should_truncate_dead_letter_reason_and_description_to_1024_characters() + { + var longReason = new string('A', 2000); + var longDescription = new string('B', 3000); + var request = new DeadLetterRequest(longReason, longDescription); + + Assert.AreEqual(new string('A', 1024), request.DeadLetterReason, "DeadLetterReason should match the first 1024 characters of the input"); + Assert.AreEqual(new string('B', 1024), request.DeadLetterErrorDescription, "DeadLetterErrorDescription should match the first 1024 characters of the input"); + } +} \ No newline at end of file diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 33d95eb..f9821bf 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -154,7 +154,7 @@ public async Task Should_dlq_when_on_error_throws_non_transient_exception() { Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); Assert.IsFalse(result.MessageActions.WasAbandoned, "Message should not be abandoned if onError throws"); - AssertExceptionWasDeadLettered(result, exception); + Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); } } @@ -177,9 +177,6 @@ public async Task Should_dlq_message_if_requested() Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); Assert.IsFalse(result.MessageActions.WasAbandoned, "Message should not be abandoned"); Assert.IsTrue(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterReason, expectedDlqReason); - Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterErrorDescription, expectedDlqDescription); - Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterProperties?["MyProperty"], "MyValue"); Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Error, "DLQ requests should be logged as error"); Assert.True(result.LogCollector.LatestRecord.Message.Contains(expectedDlqReason), "Should log DLQ reason"); Assert.True(result.LogCollector.LatestRecord.Message.Contains(expectedDlqDescription), "Should log DLQ description"); @@ -293,17 +290,6 @@ public async Task Should_dead_letter_if_header_extraction_fails() } } - void AssertExceptionWasDeadLettered(ProcessingResult result, Exception exception) - { - Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - - // Make sure we follow microsoft guidance - https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-dead-letter-queues#application-level-dead-lettering - Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterReason, $"{exception.GetType().FullName!} - {exception.Message}"); - Assert.AreEqual(result.MessageActions.DeadLetterDetails?.DeadLetterErrorDescription, exception.StackTrace); - Assert.AreEqual(Microsoft.Extensions.Logging.LogLevel.Error, result.LogCollector.LatestRecord.Level, "Dead lettering be logged as error"); - Assert.AreEqual(result.LogCollector.LatestRecord.Exception, exception); - } - async Task ProcessMessage( ServiceBusReceivedMessage? message = null, Func? onMessage = null, From 08f0a7583ce8b2960ac1b6a72a49b88c3fc76d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Wed, 25 Mar 2026 14:23:27 +0100 Subject: [PATCH 46/68] Clarify code with comment on message header usage in error handling --- .../PipelineInvokingMessageProcessor.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 4a29dab..db1919d 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -80,6 +80,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc ErrorHandleResult errorHandleResult; try { + // No need to clone the message header here since we do not make use of them after on error has executed var errorContext = new ErrorContext(exception, headers, nativeMessageId, body, azureServiceBusTransportTransaction.TransportTransaction, message.DeliveryCount, ReceiveAddress, contextBag); errorHandleResult = await onError!.Invoke(errorContext, cancellationToken).ConfigureAwait(false); } From edd2e60311fa7f5d971d22b1c6e5145c8311eae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Wed, 25 Mar 2026 14:33:18 +0100 Subject: [PATCH 47/68] Refactor dead letter request test to simulate exception handling and validate stack trace --- src/Tests/DeadLetterMessageTests.cs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Tests/DeadLetterMessageTests.cs b/src/Tests/DeadLetterMessageTests.cs index 982d0b5..8a99acb 100644 --- a/src/Tests/DeadLetterMessageTests.cs +++ b/src/Tests/DeadLetterMessageTests.cs @@ -23,13 +23,29 @@ public void Should_full_control_over_dead_letter_parameters() [Test] public void Should_convert_exception_to_dead_letter_request() { - var exception = new InvalidOperationException("Test exception"); + Exception exception; + + try + { + SimulateException(); + } + catch (Exception e) + { + exception = e; + } + var request = new DeadLetterRequest(exception); // Make sure we follow microsoft guidance - https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-dead-letter-queues#application-level-dead-lettering Assert.AreEqual("System.InvalidOperationException - Test exception", request.DeadLetterReason, "DeadLetterReason should reflect exception type and message"); - Assert.AreEqual(request.DeadLetterErrorDescription, "No stack trace available", "DeadLetterErrorDescription should contain stack trace"); + Assert.AreEqual(request.DeadLetterErrorDescription, exception.StackTrace, "DeadLetterErrorDescription should contain stack trace"); Assert.IsNull(request.PropertiesToModify, "PropertiesToModify should be null for exception-based dead lettering"); + + void SimulateException() + { + throw new InvalidOperationException("Test exception"); + ; + } } [Test] From 6d632690453a914e3863a78e5157486c275d2541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Wed, 25 Mar 2026 14:35:29 +0100 Subject: [PATCH 48/68] Formatting --- src/Tests/DeadLetterMessageTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Tests/DeadLetterMessageTests.cs b/src/Tests/DeadLetterMessageTests.cs index 8a99acb..7e1c58a 100644 --- a/src/Tests/DeadLetterMessageTests.cs +++ b/src/Tests/DeadLetterMessageTests.cs @@ -44,7 +44,6 @@ public void Should_convert_exception_to_dead_letter_request() void SimulateException() { throw new InvalidOperationException("Test exception"); - ; } } From 1186e0ed3ef860950c4d20c8352be24b204fbe51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Wed, 25 Mar 2026 14:38:37 +0100 Subject: [PATCH 49/68] Refactor exception handling in dead letter request test for clarity and efficiency --- src/Tests/DeadLetterMessageTests.cs | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/Tests/DeadLetterMessageTests.cs b/src/Tests/DeadLetterMessageTests.cs index 7e1c58a..ec57c23 100644 --- a/src/Tests/DeadLetterMessageTests.cs +++ b/src/Tests/DeadLetterMessageTests.cs @@ -23,27 +23,25 @@ public void Should_full_control_over_dead_letter_parameters() [Test] public void Should_convert_exception_to_dead_letter_request() { - Exception exception; - - try - { - SimulateException(); - } - catch (Exception e) - { - exception = e; - } - + var exception = SimulateException(); var request = new DeadLetterRequest(exception); // Make sure we follow microsoft guidance - https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-dead-letter-queues#application-level-dead-lettering Assert.AreEqual("System.InvalidOperationException - Test exception", request.DeadLetterReason, "DeadLetterReason should reflect exception type and message"); Assert.AreEqual(request.DeadLetterErrorDescription, exception.StackTrace, "DeadLetterErrorDescription should contain stack trace"); Assert.IsNull(request.PropertiesToModify, "PropertiesToModify should be null for exception-based dead lettering"); + return; - void SimulateException() + Exception SimulateException() { - throw new InvalidOperationException("Test exception"); + try + { + throw new InvalidOperationException("Test exception"); + } + catch (Exception e) + { + return e; + } } } From c34c038f36952ac67be189fa7832100ed7b3e5c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Fri, 27 Mar 2026 07:28:33 +0100 Subject: [PATCH 50/68] Check that we swallow exceptions when dead lettering --- .../PipelineInvokingMessageProcessor.cs | 28 +++++++++++------ src/Tests/MessageProcessorTests.cs | 31 ++++++++++++++++--- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index db1919d..682a9d6 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -53,7 +53,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { logger.LogError(ex, "Message dead lettered due to issues with extracting message metadata."); - await DeadLetterMessage(messageActions, message, new DeadLetterRequest(ex), CancellationToken.None).ConfigureAwait(false); + await SafeDeadLetterMessage(messageActions, message, new DeadLetterRequest(ex), CancellationToken.None).ConfigureAwait(false); return; } @@ -100,7 +100,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { logger.LogError(exception, "Message dead lettered due to exception in OnError."); - await DeadLetterMessage(messageActions, message, new DeadLetterRequest(ex), CancellationToken.None).ConfigureAwait(false); + await SafeDeadLetterMessage(messageActions, message, new DeadLetterRequest(ex), CancellationToken.None).ConfigureAwait(false); return; } @@ -108,7 +108,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { logger.LogError($"User requested {nativeMessageId} to be dead lettered due to {deadLetterRequest.DeadLetterReason}: {deadLetterRequest.DeadLetterErrorDescription}"); - await DeadLetterMessage(messageActions, message, deadLetterRequest, CancellationToken.None).ConfigureAwait(false); + await SafeDeadLetterMessage(messageActions, message, deadLetterRequest, CancellationToken.None).ConfigureAwait(false); return; } @@ -151,12 +151,22 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc return headers; } - Task DeadLetterMessage(ServiceBusMessageActions messageActions, ServiceBusReceivedMessage message, DeadLetterRequest request, CancellationToken cancellationToken) => - messageActions.DeadLetterMessageAsync(message, - request.PropertiesToModify, - request.DeadLetterReason, - request.DeadLetterErrorDescription, - cancellationToken); + async Task SafeDeadLetterMessage(ServiceBusMessageActions messageActions, ServiceBusReceivedMessage message, DeadLetterRequest request, CancellationToken cancellationToken) + { + try + { + await messageActions.DeadLetterMessageAsync(message, + request.PropertiesToModify, + request.DeadLetterReason, + request.DeadLetterErrorDescription, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (Exception ex) + { + logger.LogDebug(ex, "Dead letter message failed."); + } + } static string GetNativeMessageId(ServiceBusReceivedMessage message) { diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index f9821bf..a6e944e 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -290,8 +290,27 @@ public async Task Should_dead_letter_if_header_extraction_fails() } } + [Test] + public async Task Show_log_and_swallow_exceptions_from_dead_lettering_unless_invocation_is_cancelled() + { + var dlqException = new Exception("dlqFailed"); + var messageActions = new TestableMessageActions + { + DeadLetterMessage = (_, _, _, _, _) => throw dlqException, + }; + var result = await ProcessMessage(headerExtractor: _ => throw new Exception("simulated exception"), messageActions: messageActions); + + using (Assert.EnterMultipleScope()) + { + Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); + Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Debug, "Should be logged as debug"); + Assert.AreSame(result.LogCollector.LatestRecord.Exception, dlqException); + } + } + async Task ProcessMessage( ServiceBusReceivedMessage? message = null, + TestableMessageActions? messageActions = null, Func? onMessage = null, Func>? onError = null, Func>? headerExtractor = null, @@ -300,12 +319,12 @@ async Task ProcessMessage( message ??= ServiceBusModelFactory.ServiceBusReceivedMessage(messageId: Guid.NewGuid().ToString()); onMessage ??= (_, _) => Task.CompletedTask; onError ??= (_, _) => Task.FromResult(ErrorHandleResult.RetryRequired); + messageActions ??= new TestableMessageActions(); var fakeLogger = new FakeLogger(); var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver(), fakeLogger, headerExtractor); MessageContext? capturedMessageContext = null; ErrorContext? capturedErrorContext = null; - var messageActions = new TestableMessageActions(); await processor.Initialize(PushRuntimeSettings.Default, async (msgContext, token) => @@ -338,22 +357,26 @@ class TestableMessageActions : ServiceBusMessageActions public bool WasDeadLettered => DeadLetterDetails is not null; public DeadLetterCallDetails? DeadLetterDetails { get; private set; } + public Func? CompleteMessage { get; set; } + public Func?, CancellationToken, Task>? AbandonMessage { get; set; } + public Func?, string?, string?, CancellationToken, Task>? DeadLetterMessage { get; set; } + public override Task CompleteMessageAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = new CancellationToken()) { WasCompleted = true; - return Task.CompletedTask; + return CompleteMessage != null ? CompleteMessage(message, cancellationToken) : Task.CompletedTask; } public override Task AbandonMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = null, CancellationToken cancellationToken = new CancellationToken()) { WasAbandoned = true; - return Task.CompletedTask; + return AbandonMessage != null ? AbandonMessage(message, propertiesToModify, cancellationToken) : Task.CompletedTask; } public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, Dictionary? propertiesToModify = null, string? deadLetterReason = null, string? deadLetterErrorDescription = null, CancellationToken cancellationToken = new CancellationToken()) { DeadLetterDetails = new(deadLetterReason, deadLetterErrorDescription, propertiesToModify); - return Task.CompletedTask; + return DeadLetterMessage != null ? DeadLetterMessage(message, propertiesToModify, deadLetterReason, deadLetterErrorDescription, cancellationToken) : Task.CompletedTask; } public record DeadLetterCallDetails(string? DeadLetterReason, string? DeadLetterErrorDescription, Dictionary? DeadLetterProperties); From 0df2eceee75d139e25e8e982edeb52591a06c99d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Fri, 27 Mar 2026 09:20:28 +0100 Subject: [PATCH 51/68] Make sure auto generated message id is persisted on failure --- .../DeadLetterRequest.cs | 9 +- .../PipelineInvokingMessageProcessor.cs | 49 +++++---- src/Tests/MessageProcessorTests.cs | 101 +++++++++++++++--- 3 files changed, 123 insertions(+), 36 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs index b598789..ffa2486 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs @@ -4,18 +4,19 @@ class DeadLetterRequest { public string DeadLetterReason { get; } public string DeadLetterErrorDescription { get; } - public Dictionary? PropertiesToModify { get; } + public Dictionary PropertiesToModify { get; } public DeadLetterRequest(string deadLetterReason, string deadLetterErrorDescription, Dictionary? propertiesToModify = null) { DeadLetterReason = Truncate(deadLetterReason, 1024); DeadLetterErrorDescription = Truncate(deadLetterErrorDescription, 1024); - PropertiesToModify = propertiesToModify; + PropertiesToModify = propertiesToModify ?? []; } - public DeadLetterRequest(Exception exception) : this( + public DeadLetterRequest(Exception exception, Dictionary? propertiesToModify = null) : this( $"{exception.GetType().FullName} - {exception.Message}", - exception.StackTrace ?? "No stack trace available") + exception.StackTrace ?? "No stack trace available", + propertiesToModify) { } diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 682a9d6..81d6290 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -36,14 +36,21 @@ public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnE public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, CancellationToken cancellationToken = default) { - string nativeMessageId; + var nativeMessageId = message.MessageId; Dictionary headers; + Dictionary? messagePropertiesToModifyOnFailure = null; BinaryData body; var contextBag = new ContextBag(); + if (string.IsNullOrEmpty(nativeMessageId)) + { + nativeMessageId = Guid.NewGuid().ToString(); + + messagePropertiesToModifyOnFailure = new Dictionary { { ServiceBusMessageIdKey, nativeMessageId } }; + } + try { - nativeMessageId = GetNativeMessageId(message); headers = extractHeaders(message); body = message.Body ?? BinaryData.FromBytes(ReadOnlyMemory.Empty); @@ -53,7 +60,9 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { logger.LogError(ex, "Message dead lettered due to issues with extracting message metadata."); - await SafeDeadLetterMessage(messageActions, message, new DeadLetterRequest(ex), CancellationToken.None).ConfigureAwait(false); + var deadLetterRequest = new DeadLetterRequest(ex, messagePropertiesToModifyOnFailure); + + await SafeDeadLetterMessage(messageActions, message, deadLetterRequest, CancellationToken.None).ConfigureAwait(false); return; } @@ -87,28 +96,36 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { logger.LogDebug(ex, "OnError canceled."); - await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); + await messageActions.AbandonMessageAsync(message, propertiesToModify: messagePropertiesToModifyOnFailure, cancellationToken: CancellationToken.None).ConfigureAwait(false); return; } catch (ServiceBusException ex) when (ex.IsTransient || ex.Reason == ServiceBusFailureReason.MessageLockLost) { logger.LogWarning(ex, "OnError failed due to transient exception."); - await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); + await messageActions.AbandonMessageAsync(message, propertiesToModify: messagePropertiesToModifyOnFailure, cancellationToken: CancellationToken.None).ConfigureAwait(false); return; } catch (Exception ex) { logger.LogError(exception, "Message dead lettered due to exception in OnError."); - await SafeDeadLetterMessage(messageActions, message, new DeadLetterRequest(ex), CancellationToken.None).ConfigureAwait(false); + await SafeDeadLetterMessage(messageActions, message, new DeadLetterRequest(ex, messagePropertiesToModifyOnFailure), CancellationToken.None).ConfigureAwait(false); return; } - if (azureServiceBusTransportTransaction.TransportTransaction.TryGet(out var deadLetterRequest)) + if (azureServiceBusTransportTransaction.TransportTransaction.TryGet(out var applicationDeadLetterRequest)) { - logger.LogError($"User requested {nativeMessageId} to be dead lettered due to {deadLetterRequest.DeadLetterReason}: {deadLetterRequest.DeadLetterErrorDescription}"); + logger.LogError($"User requested {nativeMessageId} to be dead lettered due to {applicationDeadLetterRequest.DeadLetterReason}: {applicationDeadLetterRequest.DeadLetterErrorDescription}"); + + if (messagePropertiesToModifyOnFailure is not null) + { + foreach (var kvp in messagePropertiesToModifyOnFailure) + { + applicationDeadLetterRequest.PropertiesToModify[kvp.Key] = kvp.Value; + } + } - await SafeDeadLetterMessage(messageActions, message, deadLetterRequest, CancellationToken.None).ConfigureAwait(false); + await SafeDeadLetterMessage(messageActions, message, applicationDeadLetterRequest, CancellationToken.None).ConfigureAwait(false); return; } @@ -120,7 +137,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc return; } - await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); + await messageActions.AbandonMessageAsync(message, propertiesToModify: messagePropertiesToModifyOnFailure, cancellationToken: CancellationToken.None).ConfigureAwait(false); } } @@ -168,16 +185,6 @@ await messageActions.DeadLetterMessageAsync(message, } } - static string GetNativeMessageId(ServiceBusReceivedMessage message) - { - if (string.IsNullOrEmpty(message.MessageId)) - { - throw new Exception("Azure Service Bus MessageId is required, but was not found. Ensure to assign MessageId to all Service Bus messages."); - } - - return message.MessageId; - } - public Task StartReceive(CancellationToken cancellationToken = default) => Task.CompletedTask; // No-op because the rate at which Azure Functions pushes messages to the pipeline can't be controlled. @@ -195,4 +202,6 @@ static string GetNativeMessageId(ServiceBusReceivedMessage message) readonly IMessageReceiver baseTransportReceiver; readonly ILogger logger; readonly Func> extractHeaders; + + internal const string ServiceBusMessageIdKey = "MessageId"; } \ No newline at end of file diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index a6e944e..40635cd 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -54,15 +54,75 @@ public async Task Should_expose_native_message_id_headers_and_body_on_message_co } [Test] - public async Task Should_require_native_message_id_to_be_set() + public async Task Should_auto_generate_message_id_if_needed() { - var message = ServiceBusModelFactory.ServiceBusReceivedMessage(); - var result = await ProcessMessage(message: message); + string? messageId = null; + var result = await ProcessMessage(onMessage: (context, _) => + { + messageId = context.NativeMessageId; + return Task.CompletedTask; + }); using (Assert.EnterMultipleScope()) { - Assert.False(result.OnMessageWasCalled, "OnMessage should not be called"); - Assert.False(result.OnErrorWasCalled, "OnError should not be called"); - Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); + Assert.True(result.MessageActions.WasCompleted, "Message should be completed"); + Assert.NotNull(messageId); + } + } + + [Test] + public async Task Should_update_poison_dead_lettered_message_with_auto_generated_message_id() + { + Dictionary? updatedProperties = []; + var messageActions = new TestableMessageActions + { + DeadLetterMessage = (_, properties, _, _, _) => + { + updatedProperties = properties; + return Task.CompletedTask; + } + }; + var result = await ProcessMessage( + headerExtractor: _ => throw new Exception("simulated exception"), + messageActions: messageActions); + + using (Assert.EnterMultipleScope()) + { + Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); + Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.ServiceBusMessageIdKey), "MessageId property should be updated"); + var messageId = updatedProperties[PipelineInvokingMessageProcessor.ServiceBusMessageIdKey].ToString(); + Assert.True(Guid.TryParse(messageId, out _), "MessageId should be a non null value"); + } + } + + [Test] + public async Task Should_update_application_dead_lettered_message_with_auto_generated_message_id() + { + Dictionary? updatedProperties = []; + string? autoGeneratedMessageId = null; + + var messageActions = new TestableMessageActions + { + DeadLetterMessage = (_, properties, _, _, _) => + { + updatedProperties = properties; + return Task.CompletedTask; + } + }; + var result = await ProcessMessage( + onMessage: (_, _) => throw new Exception("simulated exception"), + onError: (context, _) => + { + autoGeneratedMessageId = context.Message.NativeMessageId; + context.TransportTransaction.Set(new DeadLetterRequest("reason", "description")); + return Task.FromResult(ErrorHandleResult.Handled); + }, + messageActions: messageActions); + + using (Assert.EnterMultipleScope()) + { + Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); + Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.ServiceBusMessageIdKey), "MessageId property should be updated"); + Assert.AreEqual(updatedProperties[PipelineInvokingMessageProcessor.ServiceBusMessageIdKey], autoGeneratedMessageId, "MessageId should be the auto generated value"); } } @@ -84,8 +144,26 @@ public async Task Should_complete_when_on_message_succeeds() [Test] public async Task Should_abandon_when_on_message_fails_and_retry_is_requested() { + IDictionary? updatedProperties = new Dictionary(); + string? autoGeneratedMessageId = null; + + var messageActions = new TestableMessageActions + { + AbandonMessage = (_, properties, _) => + { + updatedProperties = properties; + return Task.CompletedTask; + } + }; + var result = await ProcessMessage( - onMessage: (_, _) => throw new Exception("simulated exception")); + onMessage: (_, _) => throw new Exception("simulated exception"), + onError: (context, _) => + { + autoGeneratedMessageId = context.Message.NativeMessageId; + return Task.FromResult(ErrorHandleResult.RetryRequired); + }, + messageActions: messageActions); using (Assert.EnterMultipleScope()) { @@ -93,6 +171,8 @@ public async Task Should_abandon_when_on_message_fails_and_retry_is_requested() Assert.IsTrue(result.OnErrorWasCalled, "OnError should be called"); Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned"); + Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.ServiceBusMessageIdKey), "MessageId property should be updated"); + Assert.AreEqual(updatedProperties[PipelineInvokingMessageProcessor.ServiceBusMessageIdKey], autoGeneratedMessageId, "MessageId should be the auto generated value"); } } @@ -294,10 +374,7 @@ public async Task Should_dead_letter_if_header_extraction_fails() public async Task Show_log_and_swallow_exceptions_from_dead_lettering_unless_invocation_is_cancelled() { var dlqException = new Exception("dlqFailed"); - var messageActions = new TestableMessageActions - { - DeadLetterMessage = (_, _, _, _, _) => throw dlqException, - }; + var messageActions = new TestableMessageActions { DeadLetterMessage = (_, _, _, _, _) => throw dlqException }; var result = await ProcessMessage(headerExtractor: _ => throw new Exception("simulated exception"), messageActions: messageActions); using (Assert.EnterMultipleScope()) @@ -316,7 +393,7 @@ async Task ProcessMessage( Func>? headerExtractor = null, CancellationToken cancellationToken = default) { - message ??= ServiceBusModelFactory.ServiceBusReceivedMessage(messageId: Guid.NewGuid().ToString()); + message ??= ServiceBusModelFactory.ServiceBusReceivedMessage(); onMessage ??= (_, _) => Task.CompletedTask; onError ??= (_, _) => Task.FromResult(ErrorHandleResult.RetryRequired); messageActions ??= new TestableMessageActions(); From c007b242d8dd3e49e1f028c5a366474021596bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Fri, 27 Mar 2026 09:32:04 +0100 Subject: [PATCH 52/68] Use null or whitespace --- src/IntegrationTest.Sales/SalesEndpoint.cs | 10 +++++++++- .../PipelineInvokingMessageProcessor.cs | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/IntegrationTest.Sales/SalesEndpoint.cs b/src/IntegrationTest.Sales/SalesEndpoint.cs index 252ef75..16cbd8f 100644 --- a/src/IntegrationTest.Sales/SalesEndpoint.cs +++ b/src/IntegrationTest.Sales/SalesEndpoint.cs @@ -26,6 +26,14 @@ public static void ConfigureSales(EndpointConfiguration configuration) configuration.AddHandler(); // Use the dead letter queue for failures - configuration.Recoverability().CustomPolicy((_, context) => new DeadLetterMessage(context.Exception)); + configuration.Recoverability().CustomPolicy((_, context) => + { + if (context.ImmediateProcessingFailures == 0) + { + return RecoverabilityAction.ImmediateRetry(); + } + + return new DeadLetterMessage(context.Exception); + }); } } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 81d6290..553acb1 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -42,7 +42,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc BinaryData body; var contextBag = new ContextBag(); - if (string.IsNullOrEmpty(nativeMessageId)) + if (string.IsNullOrWhiteSpace(nativeMessageId)) { nativeMessageId = Guid.NewGuid().ToString(); From 463bd5caa3adfc00fbd9a6c25f4590a292033cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Fri, 27 Mar 2026 09:39:12 +0100 Subject: [PATCH 53/68] SafeAbandon --- .../PipelineInvokingMessageProcessor.cs | 23 ++++++++++++++--- src/Tests/MessageProcessorTests.cs | 25 ++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 553acb1..2d7a771 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -81,7 +81,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { logger.LogDebug(ex, "Message processing canceled."); - await messageActions.AbandonMessageAsync(message, cancellationToken: CancellationToken.None).ConfigureAwait(false); + await SafeAbandonMessage(messageActions, message, messagePropertiesToModifyOnFailure, CancellationToken.None).ConfigureAwait(false); } catch (Exception exception) { @@ -96,13 +96,13 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { logger.LogDebug(ex, "OnError canceled."); - await messageActions.AbandonMessageAsync(message, propertiesToModify: messagePropertiesToModifyOnFailure, cancellationToken: CancellationToken.None).ConfigureAwait(false); + await SafeAbandonMessage(messageActions, message, messagePropertiesToModifyOnFailure, CancellationToken.None).ConfigureAwait(false); return; } catch (ServiceBusException ex) when (ex.IsTransient || ex.Reason == ServiceBusFailureReason.MessageLockLost) { logger.LogWarning(ex, "OnError failed due to transient exception."); - await messageActions.AbandonMessageAsync(message, propertiesToModify: messagePropertiesToModifyOnFailure, cancellationToken: CancellationToken.None).ConfigureAwait(false); + await SafeAbandonMessage(messageActions, message, messagePropertiesToModifyOnFailure, CancellationToken.None).ConfigureAwait(false); return; } catch (Exception ex) @@ -137,7 +137,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc return; } - await messageActions.AbandonMessageAsync(message, propertiesToModify: messagePropertiesToModifyOnFailure, cancellationToken: CancellationToken.None).ConfigureAwait(false); + await SafeAbandonMessage(messageActions, message, messagePropertiesToModifyOnFailure, CancellationToken.None).ConfigureAwait(false); } } @@ -185,6 +185,21 @@ await messageActions.DeadLetterMessageAsync(message, } } + async Task SafeAbandonMessage(ServiceBusMessageActions messageActions, ServiceBusReceivedMessage message, Dictionary? propertiesToModify, CancellationToken cancellationToken) + { + try + { + await messageActions.AbandonMessageAsync(message, + propertiesToModify, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (Exception ex) + { + logger.LogDebug(ex, "Abandon message failed."); + } + } + public Task StartReceive(CancellationToken cancellationToken = default) => Task.CompletedTask; // No-op because the rate at which Azure Functions pushes messages to the pipeline can't be controlled. diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 40635cd..b9856d8 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -373,15 +373,34 @@ public async Task Should_dead_letter_if_header_extraction_fails() [Test] public async Task Show_log_and_swallow_exceptions_from_dead_lettering_unless_invocation_is_cancelled() { - var dlqException = new Exception("dlqFailed"); - var messageActions = new TestableMessageActions { DeadLetterMessage = (_, _, _, _, _) => throw dlqException }; + var exception = new Exception("dlg failed"); + var messageActions = new TestableMessageActions { DeadLetterMessage = (_, _, _, _, _) => throw exception }; var result = await ProcessMessage(headerExtractor: _ => throw new Exception("simulated exception"), messageActions: messageActions); using (Assert.EnterMultipleScope()) { Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Debug, "Should be logged as debug"); - Assert.AreSame(result.LogCollector.LatestRecord.Exception, dlqException); + Assert.AreSame(result.LogCollector.LatestRecord.Exception, exception); + } + } + + [Test] + public async Task Show_log_and_swallow_exceptions_from_abandon_unless_invocation_is_cancelled() + { + var exception = new Exception("abandon failed"); + var messageActions = new TestableMessageActions { AbandonMessage = (_, _, _) => throw exception }; + + var result = await ProcessMessage( + onMessage: (_, _) => throw new Exception("simulated exception"), + onError: (_, _) => Task.FromResult(ErrorHandleResult.RetryRequired), + messageActions: messageActions); + + using (Assert.EnterMultipleScope()) + { + Assert.True(result.MessageActions.WasAbandoned, "Message should be abandoned"); + Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Debug, "Should be logged as debug"); + Assert.AreSame(result.LogCollector.LatestRecord.Exception, exception); } } From 549e890fb56e88d35f875b51dca59fe764f33bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Fri, 27 Mar 2026 09:57:13 +0100 Subject: [PATCH 54/68] Make sure auto generated message id is used --- .../PipelineInvokingMessageProcessor.cs | 14 +++++--- src/Tests/MessageProcessorTests.cs | 32 +++++++++++++++---- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 2d7a771..4f2fd78 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -44,9 +44,15 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc if (string.IsNullOrWhiteSpace(nativeMessageId)) { - nativeMessageId = Guid.NewGuid().ToString(); - - messagePropertiesToModifyOnFailure = new Dictionary { { ServiceBusMessageIdKey, nativeMessageId } }; + if (message.ApplicationProperties.TryGetValue(AutoGeneratedServiceBusMessageIdKey, out var autoGeneratedMessageId)) + { + nativeMessageId = autoGeneratedMessageId.ToString(); + } + else + { + nativeMessageId = Guid.NewGuid().ToString(); + messagePropertiesToModifyOnFailure = new Dictionary { { AutoGeneratedServiceBusMessageIdKey, nativeMessageId } }; + } } try @@ -218,5 +224,5 @@ await messageActions.AbandonMessageAsync(message, readonly ILogger logger; readonly Func> extractHeaders; - internal const string ServiceBusMessageIdKey = "MessageId"; + internal const string AutoGeneratedServiceBusMessageIdKey = "GeneratedMessageId"; } \ No newline at end of file diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index b9856d8..716facf 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -69,6 +69,26 @@ public async Task Should_auto_generate_message_id_if_needed() } } + [Test] + public async Task Should_use_auto_generated_message_id_if_present() + { + string? messageId = null; + var autoGeneratedMessageId = Guid.NewGuid().ToString(); + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(properties: new Dictionary { { PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey, autoGeneratedMessageId } }); + var result = await ProcessMessage( + message: message, + onMessage: (context, _) => + { + messageId = context.NativeMessageId; + return Task.CompletedTask; + }); + using (Assert.EnterMultipleScope()) + { + Assert.True(result.MessageActions.WasCompleted, "Message should be completed"); + Assert.AreEqual(messageId, autoGeneratedMessageId, "Auto generated message id should used as message id"); + } + } + [Test] public async Task Should_update_poison_dead_lettered_message_with_auto_generated_message_id() { @@ -88,8 +108,8 @@ public async Task Should_update_poison_dead_lettered_message_with_auto_generated using (Assert.EnterMultipleScope()) { Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.ServiceBusMessageIdKey), "MessageId property should be updated"); - var messageId = updatedProperties[PipelineInvokingMessageProcessor.ServiceBusMessageIdKey].ToString(); + Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey), "MessageId property should be updated"); + var messageId = updatedProperties[PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey].ToString(); Assert.True(Guid.TryParse(messageId, out _), "MessageId should be a non null value"); } } @@ -121,8 +141,8 @@ public async Task Should_update_application_dead_lettered_message_with_auto_gene using (Assert.EnterMultipleScope()) { Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.ServiceBusMessageIdKey), "MessageId property should be updated"); - Assert.AreEqual(updatedProperties[PipelineInvokingMessageProcessor.ServiceBusMessageIdKey], autoGeneratedMessageId, "MessageId should be the auto generated value"); + Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey), "MessageId property should be updated"); + Assert.AreEqual(updatedProperties[PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey], autoGeneratedMessageId, "MessageId should be the auto generated value"); } } @@ -171,8 +191,8 @@ public async Task Should_abandon_when_on_message_fails_and_retry_is_requested() Assert.IsTrue(result.OnErrorWasCalled, "OnError should be called"); Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned"); - Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.ServiceBusMessageIdKey), "MessageId property should be updated"); - Assert.AreEqual(updatedProperties[PipelineInvokingMessageProcessor.ServiceBusMessageIdKey], autoGeneratedMessageId, "MessageId should be the auto generated value"); + Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey), "MessageId property should be updated"); + Assert.AreEqual(updatedProperties[PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey], autoGeneratedMessageId, "MessageId should be the auto generated value"); } } From 2582c7a4fd08f9ca4ff94cd2fb6f29884ded95b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Fri, 27 Mar 2026 10:19:37 +0100 Subject: [PATCH 55/68] Fix bug in test storage --- src/IntegrationTest.Sales/SalesEndpoint.cs | 3 ++- src/IntegrationTest.Shared/Infrastructure/TestStorage.cs | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/IntegrationTest.Sales/SalesEndpoint.cs b/src/IntegrationTest.Sales/SalesEndpoint.cs index 16cbd8f..91b7920 100644 --- a/src/IntegrationTest.Sales/SalesEndpoint.cs +++ b/src/IntegrationTest.Sales/SalesEndpoint.cs @@ -24,11 +24,12 @@ public static void ConfigureSales(EndpointConfiguration configuration) configuration.RegisterComponents(services => services.AddSingleton(new MyComponent("Sales"))); configuration.AddHandler(); + configuration.AuditProcessedMessagesTo("audit"); // Use the dead letter queue for failures configuration.Recoverability().CustomPolicy((_, context) => { - if (context.ImmediateProcessingFailures == 0) + if (context.ImmediateProcessingFailures == 1) { return RecoverabilityAction.ImmediateRetry(); } diff --git a/src/IntegrationTest.Shared/Infrastructure/TestStorage.cs b/src/IntegrationTest.Shared/Infrastructure/TestStorage.cs index 8844c3a..e2329c8 100644 --- a/src/IntegrationTest.Shared/Infrastructure/TestStorage.cs +++ b/src/IntegrationTest.Shared/Infrastructure/TestStorage.cs @@ -25,9 +25,9 @@ public Payload CreatePayload(string testName) var sortedMessages = list .OrderBy(m => m.Order) - .ThenBy(m => m.MessageType) - .ThenBy(m => m.SendingEndpoint) - .ThenBy(m => m.ReceivingEndpoint) + .ThenBy(m => m.MessageType) + .ThenBy(m => m.SendingEndpoint) + .ThenBy(m => m.ReceivingEndpoint) .ToArray(); return new Payload(sortedMessages); @@ -39,8 +39,9 @@ public class TestStorage(string endpointName, GlobalTestStorage globalStorage) public void LogMessage(string testName, T message, IMessageHandlerContext context) where T : class { - var sendingEndpoint = context.MessageHeaders[Headers.OriginatingEndpoint] ?? ""; + var sendingEndpoint = context.MessageHeaders.GetValueOrDefault(Headers.OriginatingEndpoint, ""); var storageOrder = context.Extensions.Get("TestStorageOrder"); + var rec = new MessageReceived(message.GetType().FullName!, storageOrder, sendingEndpoint, endpointName); globalStorage.Add(testName, rec); } From 70eba9da69c606cd9a9415105abf017478b96cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Fri, 27 Mar 2026 11:19:40 +0100 Subject: [PATCH 56/68] Use nsb header key for message id when storing auto generated message id --- src/IntegrationTest.Sales/SalesEndpoint.cs | 2 +- .../PipelineInvokingMessageProcessor.cs | 12 ++++++------ src/Tests/DeadLetterMessageTests.cs | 1 - src/Tests/MessageProcessorTests.cs | 14 +++++++------- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/IntegrationTest.Sales/SalesEndpoint.cs b/src/IntegrationTest.Sales/SalesEndpoint.cs index 91b7920..d8321a2 100644 --- a/src/IntegrationTest.Sales/SalesEndpoint.cs +++ b/src/IntegrationTest.Sales/SalesEndpoint.cs @@ -29,7 +29,7 @@ public static void ConfigureSales(EndpointConfiguration configuration) // Use the dead letter queue for failures configuration.Recoverability().CustomPolicy((_, context) => { - if (context.ImmediateProcessingFailures == 1) + if (context.ImmediateProcessingFailures < 3) { return RecoverabilityAction.ImmediateRetry(); } diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 4f2fd78..c1c1184 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -19,6 +19,7 @@ public PipelineInvokingMessageProcessor(IMessageReceiver baseTransportReceiver, this.baseTransportReceiver = baseTransportReceiver; this.logger = logger; + // we do this to enable tests to simulate exceptions when extracting headers extractHeaders = headerExtractor ?? GetNServiceBusHeaders; } @@ -44,14 +45,16 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc if (string.IsNullOrWhiteSpace(nativeMessageId)) { - if (message.ApplicationProperties.TryGetValue(AutoGeneratedServiceBusMessageIdKey, out var autoGeneratedMessageId)) + if (message.ApplicationProperties.TryGetValue(Headers.MessageId, out var nsbMessageId)) { - nativeMessageId = autoGeneratedMessageId.ToString(); + nativeMessageId = nsbMessageId.ToString(); } else { nativeMessageId = Guid.NewGuid().ToString(); - messagePropertiesToModifyOnFailure = new Dictionary { { AutoGeneratedServiceBusMessageIdKey, nativeMessageId } }; + + // this makes sure that if we abandon or dead letter the message the ID we generated will be used as the message id + messagePropertiesToModifyOnFailure = new Dictionary { { Headers.MessageId, nativeMessageId } }; } } @@ -184,7 +187,6 @@ await messageActions.DeadLetterMessageAsync(message, request.DeadLetterErrorDescription, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } catch (Exception ex) { logger.LogDebug(ex, "Dead letter message failed."); @@ -223,6 +225,4 @@ await messageActions.AbandonMessageAsync(message, readonly IMessageReceiver baseTransportReceiver; readonly ILogger logger; readonly Func> extractHeaders; - - internal const string AutoGeneratedServiceBusMessageIdKey = "GeneratedMessageId"; } \ No newline at end of file diff --git a/src/Tests/DeadLetterMessageTests.cs b/src/Tests/DeadLetterMessageTests.cs index ec57c23..4c8d727 100644 --- a/src/Tests/DeadLetterMessageTests.cs +++ b/src/Tests/DeadLetterMessageTests.cs @@ -29,7 +29,6 @@ public void Should_convert_exception_to_dead_letter_request() // Make sure we follow microsoft guidance - https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-dead-letter-queues#application-level-dead-lettering Assert.AreEqual("System.InvalidOperationException - Test exception", request.DeadLetterReason, "DeadLetterReason should reflect exception type and message"); Assert.AreEqual(request.DeadLetterErrorDescription, exception.StackTrace, "DeadLetterErrorDescription should contain stack trace"); - Assert.IsNull(request.PropertiesToModify, "PropertiesToModify should be null for exception-based dead lettering"); return; Exception SimulateException() diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 716facf..e9e12f5 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -74,7 +74,7 @@ public async Task Should_use_auto_generated_message_id_if_present() { string? messageId = null; var autoGeneratedMessageId = Guid.NewGuid().ToString(); - var message = ServiceBusModelFactory.ServiceBusReceivedMessage(properties: new Dictionary { { PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey, autoGeneratedMessageId } }); + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(properties: new Dictionary { { Headers.MessageId, autoGeneratedMessageId } }); var result = await ProcessMessage( message: message, onMessage: (context, _) => @@ -108,8 +108,8 @@ public async Task Should_update_poison_dead_lettered_message_with_auto_generated using (Assert.EnterMultipleScope()) { Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey), "MessageId property should be updated"); - var messageId = updatedProperties[PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey].ToString(); + Assert.IsTrue(updatedProperties.ContainsKey(Headers.MessageId), "MessageId property should be updated"); + var messageId = updatedProperties[Headers.MessageId].ToString(); Assert.True(Guid.TryParse(messageId, out _), "MessageId should be a non null value"); } } @@ -141,8 +141,8 @@ public async Task Should_update_application_dead_lettered_message_with_auto_gene using (Assert.EnterMultipleScope()) { Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey), "MessageId property should be updated"); - Assert.AreEqual(updatedProperties[PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey], autoGeneratedMessageId, "MessageId should be the auto generated value"); + Assert.IsTrue(updatedProperties.ContainsKey(Headers.MessageId), "MessageId property should be updated"); + Assert.AreEqual(updatedProperties[Headers.MessageId], autoGeneratedMessageId, "MessageId should be the auto generated value"); } } @@ -191,8 +191,8 @@ public async Task Should_abandon_when_on_message_fails_and_retry_is_requested() Assert.IsTrue(result.OnErrorWasCalled, "OnError should be called"); Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned"); - Assert.IsTrue(updatedProperties.ContainsKey(PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey), "MessageId property should be updated"); - Assert.AreEqual(updatedProperties[PipelineInvokingMessageProcessor.AutoGeneratedServiceBusMessageIdKey], autoGeneratedMessageId, "MessageId should be the auto generated value"); + Assert.IsTrue(updatedProperties.ContainsKey(Headers.MessageId), "MessageId property should be updated"); + Assert.AreEqual(updatedProperties[Headers.MessageId], autoGeneratedMessageId, "MessageId should be the auto generated value"); } } From 587e782d174519d23969c747fb113a9650991f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Fri, 27 Mar 2026 13:18:10 +0100 Subject: [PATCH 57/68] Cache message ids that have been failes during complete and complete them immediately on retry --- .../AzureServiceBusServerlessTransport.cs | 3 +- .../PipelineInvokingMessageProcessor.cs | 39 +++++++++++++++++-- src/Tests/MessageProcessorTests.cs | 35 ++++++++++++++++- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs index 6cd3fcc..683b80b 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs @@ -7,6 +7,7 @@ namespace NServiceBus; using System.Threading.Tasks; using Azure.Core; using AzureFunctions.AzureServiceBus; +using BitFaster.Caching.Lru; using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -54,7 +55,7 @@ public override async Task Initialize( .ConfigureAwait(false); var serverlessTransportInfrastructure = new ServerlessTransportInfrastructure(baseTransportInfrastructure, - receiver => new PipelineInvokingMessageProcessor(receiver, hostSettings.ServiceProvider.GetRequiredService>())); + receiver => new PipelineInvokingMessageProcessor(receiver, new FastConcurrentLru(1_000), hostSettings.ServiceProvider.GetRequiredService>())); var isSendOnly = hostSettings.CoreSettings.GetOrDefault(SendOnlyConfigKey); diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index c1c1184..697f9fb 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -4,6 +4,7 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus; using System.Threading; using System.Threading.Tasks; using Azure.Messaging.ServiceBus; +using BitFaster.Caching; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; using NServiceBus.Extensibility; @@ -13,10 +14,12 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus; class PipelineInvokingMessageProcessor : IMessageReceiver { public PipelineInvokingMessageProcessor(IMessageReceiver baseTransportReceiver, + ICache messagesToBeCompleted, ILogger logger, Func>? headerExtractor = null) { this.baseTransportReceiver = baseTransportReceiver; + this.messagesToBeCompleted = messagesToBeCompleted; this.logger = logger; // we do this to enable tests to simulate exceptions when extracting headers @@ -37,7 +40,7 @@ public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnE public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, CancellationToken cancellationToken = default) { - var nativeMessageId = message.MessageId; + string nativeMessageId = message.MessageId; Dictionary headers; Dictionary? messagePropertiesToModifyOnFailure = null; BinaryData body; @@ -47,7 +50,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { if (message.ApplicationProperties.TryGetValue(Headers.MessageId, out var nsbMessageId)) { - nativeMessageId = nsbMessageId.ToString(); + nativeMessageId = nsbMessageId.ToString()!; } else { @@ -58,6 +61,14 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc } } + if (messagesToBeCompleted.TryRemove(nativeMessageId)) + { + logger.LogInformation($"Message {nativeMessageId} was already processed and will be completed"); + + await SafeCompleteMessage(messageActions, nativeMessageId, message, CancellationToken.None).ConfigureAwait(false); + return; + } + try { headers = extractHeaders(message); @@ -85,7 +96,8 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc await onMessage!(messageContext, cancellationToken).ConfigureAwait(false); azureServiceBusTransportTransaction.Commit(); - await messageActions.CompleteMessageAsync(message, CancellationToken.None).ConfigureAwait(false); + + await SafeCompleteMessage(messageActions, nativeMessageId, message, CancellationToken.None).ConfigureAwait(false); } catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { @@ -142,7 +154,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc if (errorHandleResult == ErrorHandleResult.Handled) { azureServiceBusTransportTransaction.Commit(); - await messageActions.CompleteMessageAsync(message, CancellationToken.None).ConfigureAwait(false); + await SafeCompleteMessage(messageActions, nativeMessageId, message, CancellationToken.None).ConfigureAwait(false); return; } @@ -187,6 +199,7 @@ await messageActions.DeadLetterMessageAsync(message, request.DeadLetterErrorDescription, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } catch (Exception ex) { logger.LogDebug(ex, "Dead letter message failed."); @@ -208,6 +221,23 @@ await messageActions.AbandonMessageAsync(message, } } + async Task SafeCompleteMessage(ServiceBusMessageActions messageActions, string nativeMessageId, ServiceBusReceivedMessage message, CancellationToken cancellationToken) + { + try + { + await messageActions.CompleteMessageAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + messagesToBeCompleted.AddOrUpdate(nativeMessageId, true); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Complete message failed."); + messagesToBeCompleted.AddOrUpdate(nativeMessageId, true); + } + } + public Task StartReceive(CancellationToken cancellationToken = default) => Task.CompletedTask; // No-op because the rate at which Azure Functions pushes messages to the pipeline can't be controlled. @@ -223,6 +253,7 @@ await messageActions.AbandonMessageAsync(message, OnError? onError; readonly IMessageReceiver baseTransportReceiver; + readonly ICache messagesToBeCompleted; readonly ILogger logger; readonly Func> extractHeaders; } \ No newline at end of file diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index e9e12f5..3d8d0d2 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -2,6 +2,8 @@ namespace NServiceBus.AzureFunctions.Tests; using Azure.Messaging.ServiceBus; using AzureServiceBus; +using BitFaster.Caching; +using BitFaster.Caching.Lru; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging.Testing; using NUnit.Framework; @@ -146,6 +148,34 @@ public async Task Should_update_application_dead_lettered_message_with_auto_gene } } + [Test] + public async Task Should_complete_messages_immediately_if_previous_processing_attempt_was_successful() + { + var completedMessagesCache = new FastConcurrentLru(1_000); + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(messageId: Guid.NewGuid().ToString()); + + var firstProcessingResult = await ProcessMessage( + message: message, + completedMessagesCache: completedMessagesCache, + messageActions: new TestableMessageActions { CompleteMessage = (_, _) => throw new Exception("simulated complete exception") }); + + using (Assert.EnterMultipleScope()) + { + Assert.IsTrue(firstProcessingResult.OnMessageWasCalled, "OnMessage should be called"); + Assert.IsTrue(firstProcessingResult.MessageActions.WasCompleted, "Message should be completed"); + } + + var secondProcessingResult = await ProcessMessage( + message: message, + completedMessagesCache: completedMessagesCache); + + using (Assert.EnterMultipleScope()) + { + Assert.IsFalse(secondProcessingResult.OnMessageWasCalled, "OnMessage should not be called"); + Assert.IsTrue(secondProcessingResult.MessageActions.WasCompleted, "Message should be completed"); + } + } + [Test] public async Task Should_complete_when_on_message_succeeds() { @@ -427,6 +457,7 @@ public async Task Show_log_and_swallow_exceptions_from_abandon_unless_invocation async Task ProcessMessage( ServiceBusReceivedMessage? message = null, TestableMessageActions? messageActions = null, + ICache? completedMessagesCache = null, Func? onMessage = null, Func>? onError = null, Func>? headerExtractor = null, @@ -436,9 +467,11 @@ async Task ProcessMessage( onMessage ??= (_, _) => Task.CompletedTask; onError ??= (_, _) => Task.FromResult(ErrorHandleResult.RetryRequired); messageActions ??= new TestableMessageActions(); + completedMessagesCache ??= new FastConcurrentLru(1_000); + var fakeLogger = new FakeLogger(); - var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver(), fakeLogger, headerExtractor); + var processor = new PipelineInvokingMessageProcessor(new FakeBaseReceiver(), completedMessagesCache, fakeLogger, headerExtractor); MessageContext? capturedMessageContext = null; ErrorContext? capturedErrorContext = null; From 07ab30b42b9f66ada86b01d917df24384869c3f8 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Mon, 30 Mar 2026 15:02:44 +0200 Subject: [PATCH 58/68] Refactor logging to use `LoggerMessage` source generator for improved performance and maintainability. --- .../PipelineInvokingMessageProcessor.cs | 88 ++++++++++++++++--- 1 file changed, 76 insertions(+), 12 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 697f9fb..5cb4680 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -7,9 +7,10 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus; using BitFaster.Caching; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; -using NServiceBus.Extensibility; -using NServiceBus.Transport; +using Extensibility; +using Transport; using NServiceBus.Transport.AzureServiceBus; +using static PipelineInvokingMessageProcessorLog; class PipelineInvokingMessageProcessor : IMessageReceiver { @@ -63,7 +64,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc if (messagesToBeCompleted.TryRemove(nativeMessageId)) { - logger.LogInformation($"Message {nativeMessageId} was already processed and will be completed"); + MessageAlreadyProcessed(logger, nativeMessageId); await SafeCompleteMessage(messageActions, nativeMessageId, message, CancellationToken.None).ConfigureAwait(false); return; @@ -78,7 +79,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc } catch (Exception ex) { - logger.LogError(ex, "Message dead lettered due to issues with extracting message metadata."); + MessageDeadLetteredDueToMetadataExtractionFailure(logger, ex); var deadLetterRequest = new DeadLetterRequest(ex, messagePropertiesToModifyOnFailure); @@ -101,7 +102,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc } catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { - logger.LogDebug(ex, "Message processing canceled."); + MessageProcessingCanceled(logger, ex); await SafeAbandonMessage(messageActions, message, messagePropertiesToModifyOnFailure, CancellationToken.None).ConfigureAwait(false); } catch (Exception exception) @@ -116,19 +117,19 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc } catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { - logger.LogDebug(ex, "OnError canceled."); + OnErrorCanceled(logger, ex); await SafeAbandonMessage(messageActions, message, messagePropertiesToModifyOnFailure, CancellationToken.None).ConfigureAwait(false); return; } catch (ServiceBusException ex) when (ex.IsTransient || ex.Reason == ServiceBusFailureReason.MessageLockLost) { - logger.LogWarning(ex, "OnError failed due to transient exception."); + OnErrorFailedDueToTransientException(logger, ex); await SafeAbandonMessage(messageActions, message, messagePropertiesToModifyOnFailure, CancellationToken.None).ConfigureAwait(false); return; } catch (Exception ex) { - logger.LogError(exception, "Message dead lettered due to exception in OnError."); + MessageDeadLetteredDueToExceptionInOnError(logger, exception); await SafeDeadLetterMessage(messageActions, message, new DeadLetterRequest(ex, messagePropertiesToModifyOnFailure), CancellationToken.None).ConfigureAwait(false); return; @@ -136,7 +137,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc if (azureServiceBusTransportTransaction.TransportTransaction.TryGet(out var applicationDeadLetterRequest)) { - logger.LogError($"User requested {nativeMessageId} to be dead lettered due to {applicationDeadLetterRequest.DeadLetterReason}: {applicationDeadLetterRequest.DeadLetterErrorDescription}"); + UserRequestedDeadLetter(logger, nativeMessageId, applicationDeadLetterRequest.DeadLetterReason, applicationDeadLetterRequest.DeadLetterErrorDescription); if (messagePropertiesToModifyOnFailure is not null) { @@ -202,7 +203,7 @@ await messageActions.DeadLetterMessageAsync(message, catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } catch (Exception ex) { - logger.LogDebug(ex, "Dead letter message failed."); + DeadLetterMessageFailed(logger, ex); } } @@ -217,7 +218,7 @@ await messageActions.AbandonMessageAsync(message, catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } catch (Exception ex) { - logger.LogDebug(ex, "Abandon message failed."); + AbandonMessageFailed(logger, ex); } } @@ -233,7 +234,7 @@ async Task SafeCompleteMessage(ServiceBusMessageActions messageActions, string n } catch (Exception ex) { - logger.LogDebug(ex, "Complete message failed."); + CompleteMessageFailed(logger, ex); messagesToBeCompleted.AddOrUpdate(nativeMessageId, true); } } @@ -256,4 +257,67 @@ async Task SafeCompleteMessage(ServiceBusMessageActions messageActions, string n readonly ICache messagesToBeCompleted; readonly ILogger logger; readonly Func> extractHeaders; +} + +static partial class PipelineInvokingMessageProcessorLog +{ + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = "Message {MessageId} was already processed and will be completed")] + internal static partial void MessageAlreadyProcessed(ILogger logger, string messageId); + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Error, + Message = "Message dead lettered due to issues with extracting message metadata.")] + internal static partial void MessageDeadLetteredDueToMetadataExtractionFailure(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Debug, + Message = "Message processing canceled.")] + internal static partial void MessageProcessingCanceled(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 3, + Level = LogLevel.Debug, + Message = "OnError canceled.")] + internal static partial void OnErrorCanceled(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 4, + Level = LogLevel.Warning, + Message = "OnError failed due to transient exception.")] + internal static partial void OnErrorFailedDueToTransientException(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 5, + Level = LogLevel.Error, + Message = "Message dead lettered due to exception in OnError.")] + internal static partial void MessageDeadLetteredDueToExceptionInOnError(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 6, + Level = LogLevel.Error, + Message = "User requested {MessageId} to be dead lettered due to {DeadLetterReason}: {DeadLetterErrorDescription}")] + internal static partial void UserRequestedDeadLetter(ILogger logger, string messageId, string deadLetterReason, string deadLetterErrorDescription); + + [LoggerMessage( + EventId = 7, + Level = LogLevel.Debug, + Message = "Dead letter message failed.")] + internal static partial void DeadLetterMessageFailed(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 8, + Level = LogLevel.Debug, + Message = "Abandon message failed.")] + internal static partial void AbandonMessageFailed(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 9, + Level = LogLevel.Debug, + Message = "Complete message failed.")] + internal static partial void CompleteMessageFailed(ILogger logger, Exception exception); } \ No newline at end of file From a413996749f4e72a4b2cda3ca80a7845251e6f87 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Mon, 30 Mar 2026 15:03:58 +0200 Subject: [PATCH 59/68] Update log level from Debug to Warning for message failure scenarios --- .../PipelineInvokingMessageProcessor.cs | 6 +++--- src/Tests/MessageProcessorTests.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 5cb4680..5c690c9 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -305,19 +305,19 @@ static partial class PipelineInvokingMessageProcessorLog [LoggerMessage( EventId = 7, - Level = LogLevel.Debug, + Level = LogLevel.Warning, Message = "Dead letter message failed.")] internal static partial void DeadLetterMessageFailed(ILogger logger, Exception exception); [LoggerMessage( EventId = 8, - Level = LogLevel.Debug, + Level = LogLevel.Warning, Message = "Abandon message failed.")] internal static partial void AbandonMessageFailed(ILogger logger, Exception exception); [LoggerMessage( EventId = 9, - Level = LogLevel.Debug, + Level = LogLevel.Warning, Message = "Complete message failed.")] internal static partial void CompleteMessageFailed(ILogger logger, Exception exception); } \ No newline at end of file diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 3d8d0d2..9e64542 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -430,7 +430,7 @@ public async Task Show_log_and_swallow_exceptions_from_dead_lettering_unless_inv using (Assert.EnterMultipleScope()) { Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Debug, "Should be logged as debug"); + Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Warning, "Should be logged as warning"); Assert.AreSame(result.LogCollector.LatestRecord.Exception, exception); } } @@ -449,7 +449,7 @@ public async Task Show_log_and_swallow_exceptions_from_abandon_unless_invocation using (Assert.EnterMultipleScope()) { Assert.True(result.MessageActions.WasAbandoned, "Message should be abandoned"); - Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Debug, "Should be logged as debug"); + Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Warning, "Should be logged as warning"); Assert.AreSame(result.LogCollector.LatestRecord.Exception, exception); } } From 4b7a1fd87dfbaa7b65d708421b326b4ba89c5eef Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Mon, 30 Mar 2026 15:07:11 +0200 Subject: [PATCH 60/68] Use `Guid.CreateVersion7` for generating `nativeMessageId` in message processor --- .../PipelineInvokingMessageProcessor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 5c690c9..ad1e434 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -55,7 +55,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc } else { - nativeMessageId = Guid.NewGuid().ToString(); + nativeMessageId = Guid.CreateVersion7().ToString(); // this makes sure that if we abandon or dead letter the message the ID we generated will be used as the message id messagePropertiesToModifyOnFailure = new Dictionary { { Headers.MessageId, nativeMessageId } }; From d92b0dbe01a31b321c5cd279388107652c1f6119 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Mon, 30 Mar 2026 15:07:26 +0200 Subject: [PATCH 61/68] Simplify message body initialization by replacing `BinaryData.FromBytes(ReadOnlyMemory.Empty)` with `BinaryData.Empty`. --- .../PipelineInvokingMessageProcessor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index ad1e434..767cef3 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -73,7 +73,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc try { headers = extractHeaders(message); - body = message.Body ?? BinaryData.FromBytes(ReadOnlyMemory.Empty); + body = message.Body ?? BinaryData.Empty; contextBag.Set(message); } From 120837fcac24432c5a323d49e8f10b784b756005 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Mon, 30 Mar 2026 15:43:54 +0200 Subject: [PATCH 62/68] Replace `Guid.CreateVersion7` with `GuidHelper.CreateVersion8` for deriving message IDs based on enqueued time and sequence number, ensuring stability across redeliveries. --- .../GuidHelper.cs | 29 +++++ .../PipelineInvokingMessageProcessor.cs | 32 ++---- src/Tests/MessageProcessorTests.cs | 103 +++++------------- 3 files changed, 68 insertions(+), 96 deletions(-) create mode 100644 src/NServiceBus.AzureFunctions.AzureServiceBus/GuidHelper.cs diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/GuidHelper.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/GuidHelper.cs new file mode 100644 index 0000000..e428e1f --- /dev/null +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/GuidHelper.cs @@ -0,0 +1,29 @@ +namespace NServiceBus.AzureFunctions.AzureServiceBus; + +using System; +using System.Buffers.Binary; + +/// +/// Provides helper methods for working with . +/// +/// +/// Inspired by NGuid by Bradley Grainger, +/// used under the MIT License. +/// +static class GuidHelper +{ + /// + /// Creates a Version 8 UUID with a v7-style layout: as Unix + /// milliseconds in bytes 0–7 (time-sortable, stable across redeliveries) and + /// in bytes 8–15, both big-endian. + /// + public static Guid CreateVersion8(DateTimeOffset timestamp, long sequenceNumber) + { + Span guidBytes = stackalloc byte[16]; + BinaryPrimitives.WriteInt64BigEndian(guidBytes, timestamp.ToUnixTimeMilliseconds()); + BinaryPrimitives.WriteInt64BigEndian(guidBytes[8..], sequenceNumber); + guidBytes[6] = (byte)(0x80 | (guidBytes[6] & 0xF)); + guidBytes[8] = (byte)(0x80 | (guidBytes[8] & 0x3F)); + return new Guid(guidBytes, bigEndian: true); + } +} diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 767cef3..bee022e 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -43,7 +43,6 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { string nativeMessageId = message.MessageId; Dictionary headers; - Dictionary? messagePropertiesToModifyOnFailure = null; BinaryData body; var contextBag = new ContextBag(); @@ -55,10 +54,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc } else { - nativeMessageId = Guid.CreateVersion7().ToString(); - - // this makes sure that if we abandon or dead letter the message the ID we generated will be used as the message id - messagePropertiesToModifyOnFailure = new Dictionary { { Headers.MessageId, nativeMessageId } }; + nativeMessageId = GuidHelper.CreateVersion8(message.EnqueuedTime, message.SequenceNumber).ToString(); } } @@ -81,7 +77,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { MessageDeadLetteredDueToMetadataExtractionFailure(logger, ex); - var deadLetterRequest = new DeadLetterRequest(ex, messagePropertiesToModifyOnFailure); + var deadLetterRequest = new DeadLetterRequest(ex); await SafeDeadLetterMessage(messageActions, message, deadLetterRequest, CancellationToken.None).ConfigureAwait(false); return; @@ -103,7 +99,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { MessageProcessingCanceled(logger, ex); - await SafeAbandonMessage(messageActions, message, messagePropertiesToModifyOnFailure, CancellationToken.None).ConfigureAwait(false); + await SafeAbandonMessage(messageActions, message, CancellationToken.None).ConfigureAwait(false); } catch (Exception exception) { @@ -118,20 +114,20 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { OnErrorCanceled(logger, ex); - await SafeAbandonMessage(messageActions, message, messagePropertiesToModifyOnFailure, CancellationToken.None).ConfigureAwait(false); + await SafeAbandonMessage(messageActions, message, CancellationToken.None).ConfigureAwait(false); return; } catch (ServiceBusException ex) when (ex.IsTransient || ex.Reason == ServiceBusFailureReason.MessageLockLost) { OnErrorFailedDueToTransientException(logger, ex); - await SafeAbandonMessage(messageActions, message, messagePropertiesToModifyOnFailure, CancellationToken.None).ConfigureAwait(false); + await SafeAbandonMessage(messageActions, message, CancellationToken.None).ConfigureAwait(false); return; } catch (Exception ex) { MessageDeadLetteredDueToExceptionInOnError(logger, exception); - await SafeDeadLetterMessage(messageActions, message, new DeadLetterRequest(ex, messagePropertiesToModifyOnFailure), CancellationToken.None).ConfigureAwait(false); + await SafeDeadLetterMessage(messageActions, message, new DeadLetterRequest(ex), CancellationToken.None).ConfigureAwait(false); return; } @@ -139,14 +135,6 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { UserRequestedDeadLetter(logger, nativeMessageId, applicationDeadLetterRequest.DeadLetterReason, applicationDeadLetterRequest.DeadLetterErrorDescription); - if (messagePropertiesToModifyOnFailure is not null) - { - foreach (var kvp in messagePropertiesToModifyOnFailure) - { - applicationDeadLetterRequest.PropertiesToModify[kvp.Key] = kvp.Value; - } - } - await SafeDeadLetterMessage(messageActions, message, applicationDeadLetterRequest, CancellationToken.None).ConfigureAwait(false); return; @@ -159,7 +147,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc return; } - await SafeAbandonMessage(messageActions, message, messagePropertiesToModifyOnFailure, CancellationToken.None).ConfigureAwait(false); + await SafeAbandonMessage(messageActions, message, CancellationToken.None).ConfigureAwait(false); } } @@ -207,13 +195,11 @@ await messageActions.DeadLetterMessageAsync(message, } } - async Task SafeAbandonMessage(ServiceBusMessageActions messageActions, ServiceBusReceivedMessage message, Dictionary? propertiesToModify, CancellationToken cancellationToken) + async Task SafeAbandonMessage(ServiceBusMessageActions messageActions, ServiceBusReceivedMessage message, CancellationToken cancellationToken) { try { - await messageActions.AbandonMessageAsync(message, - propertiesToModify, - cancellationToken).ConfigureAwait(false); + await messageActions.AbandonMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } catch (Exception ex) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 9e64542..6096c45 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -56,18 +56,23 @@ public async Task Should_expose_native_message_id_headers_and_body_on_message_co } [Test] - public async Task Should_auto_generate_message_id_if_needed() + public async Task Should_derive_message_id_from_enqueued_time_and_sequence_number_if_no_native_id_is_present() { - string? messageId = null; - var result = await ProcessMessage(onMessage: (context, _) => - { - messageId = context.NativeMessageId; - return Task.CompletedTask; - }); + var sequenceNumber = 42L; + var enqueuedTime = DateTimeOffset.UtcNow; + var expectedMessageId = GuidHelper.CreateVersion8(enqueuedTime, sequenceNumber).ToString(); + + string? firstMessageId = null; + string? secondMessageId = null; + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(sequenceNumber: sequenceNumber, enqueuedTime: enqueuedTime); + + await ProcessMessage(message: message, onMessage: (context, _) => { firstMessageId = context.NativeMessageId; return Task.CompletedTask; }); + await ProcessMessage(message: message, onMessage: (context, _) => { secondMessageId = context.NativeMessageId; return Task.CompletedTask; }); + using (Assert.EnterMultipleScope()) { - Assert.True(result.MessageActions.WasCompleted, "Message should be completed"); - Assert.NotNull(messageId); + Assert.That(firstMessageId, Is.EqualTo(expectedMessageId), "MessageId should be derived from enqueued time and sequence number"); + Assert.That(secondMessageId, Is.EqualTo(firstMessageId), "MessageId should be stable across processing attempts"); } } @@ -92,59 +97,30 @@ public async Task Should_use_auto_generated_message_id_if_present() } [Test] - public async Task Should_update_poison_dead_lettered_message_with_auto_generated_message_id() + public async Task Should_complete_messages_with_sequence_number_derived_id_immediately_if_previous_processing_attempt_was_successful() { - Dictionary? updatedProperties = []; - var messageActions = new TestableMessageActions - { - DeadLetterMessage = (_, properties, _, _, _) => - { - updatedProperties = properties; - return Task.CompletedTask; - } - }; - var result = await ProcessMessage( - headerExtractor: _ => throw new Exception("simulated exception"), - messageActions: messageActions); + var completedMessagesCache = new FastConcurrentLru(1_000); + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(sequenceNumber: 99L); + + var firstProcessingResult = await ProcessMessage( + message: message, + completedMessagesCache: completedMessagesCache, + messageActions: new TestableMessageActions { CompleteMessage = (_, _) => throw new Exception("simulated complete exception") }); using (Assert.EnterMultipleScope()) { - Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - Assert.IsTrue(updatedProperties.ContainsKey(Headers.MessageId), "MessageId property should be updated"); - var messageId = updatedProperties[Headers.MessageId].ToString(); - Assert.True(Guid.TryParse(messageId, out _), "MessageId should be a non null value"); + Assert.IsTrue(firstProcessingResult.OnMessageWasCalled, "OnMessage should be called"); + Assert.IsTrue(firstProcessingResult.MessageActions.WasCompleted, "Message should be completed"); } - } - - [Test] - public async Task Should_update_application_dead_lettered_message_with_auto_generated_message_id() - { - Dictionary? updatedProperties = []; - string? autoGeneratedMessageId = null; - var messageActions = new TestableMessageActions - { - DeadLetterMessage = (_, properties, _, _, _) => - { - updatedProperties = properties; - return Task.CompletedTask; - } - }; - var result = await ProcessMessage( - onMessage: (_, _) => throw new Exception("simulated exception"), - onError: (context, _) => - { - autoGeneratedMessageId = context.Message.NativeMessageId; - context.TransportTransaction.Set(new DeadLetterRequest("reason", "description")); - return Task.FromResult(ErrorHandleResult.Handled); - }, - messageActions: messageActions); + var secondProcessingResult = await ProcessMessage( + message: message, + completedMessagesCache: completedMessagesCache); using (Assert.EnterMultipleScope()) { - Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - Assert.IsTrue(updatedProperties.ContainsKey(Headers.MessageId), "MessageId property should be updated"); - Assert.AreEqual(updatedProperties[Headers.MessageId], autoGeneratedMessageId, "MessageId should be the auto generated value"); + Assert.IsFalse(secondProcessingResult.OnMessageWasCalled, "OnMessage should not be called on second delivery"); + Assert.IsTrue(secondProcessingResult.MessageActions.WasCompleted, "Message should be completed on second delivery"); } } @@ -194,26 +170,9 @@ public async Task Should_complete_when_on_message_succeeds() [Test] public async Task Should_abandon_when_on_message_fails_and_retry_is_requested() { - IDictionary? updatedProperties = new Dictionary(); - string? autoGeneratedMessageId = null; - - var messageActions = new TestableMessageActions - { - AbandonMessage = (_, properties, _) => - { - updatedProperties = properties; - return Task.CompletedTask; - } - }; - var result = await ProcessMessage( onMessage: (_, _) => throw new Exception("simulated exception"), - onError: (context, _) => - { - autoGeneratedMessageId = context.Message.NativeMessageId; - return Task.FromResult(ErrorHandleResult.RetryRequired); - }, - messageActions: messageActions); + onError: (_, _) => Task.FromResult(ErrorHandleResult.RetryRequired)); using (Assert.EnterMultipleScope()) { @@ -221,8 +180,6 @@ public async Task Should_abandon_when_on_message_fails_and_retry_is_requested() Assert.IsTrue(result.OnErrorWasCalled, "OnError should be called"); Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned"); - Assert.IsTrue(updatedProperties.ContainsKey(Headers.MessageId), "MessageId property should be updated"); - Assert.AreEqual(updatedProperties[Headers.MessageId], autoGeneratedMessageId, "MessageId should be the auto generated value"); } } From ffdfc8bbc1df7c8cf75cfa148382276c891eb739 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Mon, 30 Mar 2026 15:44:22 +0200 Subject: [PATCH 63/68] Simplify `nativeMessageId` initialization using a ternary operator for cleaner logic. --- .../PipelineInvokingMessageProcessor.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index bee022e..49a2fce 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -48,14 +48,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc if (string.IsNullOrWhiteSpace(nativeMessageId)) { - if (message.ApplicationProperties.TryGetValue(Headers.MessageId, out var nsbMessageId)) - { - nativeMessageId = nsbMessageId.ToString()!; - } - else - { - nativeMessageId = GuidHelper.CreateVersion8(message.EnqueuedTime, message.SequenceNumber).ToString(); - } + nativeMessageId = message.ApplicationProperties.TryGetValue(Headers.MessageId, out var nsbMessageId) ? nsbMessageId.ToString()! : GuidHelper.CreateVersion8(message.EnqueuedTime, message.SequenceNumber).ToString(); } if (messagesToBeCompleted.TryRemove(nativeMessageId)) From 4d816e4a0c6b93b4c9558ce55684ff72cbefc845 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Mon, 30 Mar 2026 15:46:21 +0200 Subject: [PATCH 64/68] Replace `Assert` methods with `Assert.That` for consistency and improved readability in test assertions. --- src/Tests/MessageProcessorTests.cs | 146 ++++++++++++++--------------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/src/Tests/MessageProcessorTests.cs b/src/Tests/MessageProcessorTests.cs index 6096c45..78a75e6 100644 --- a/src/Tests/MessageProcessorTests.cs +++ b/src/Tests/MessageProcessorTests.cs @@ -41,17 +41,17 @@ public async Task Should_expose_native_message_id_headers_and_body_on_message_co using (Assert.EnterMultipleScope()) { - Assert.NotNull(messageContext, "MessageContext should not be null"); - Assert.AreEqual(expectedMessageId, messageContext!.NativeMessageId, "MessageContext should expose the native message id"); - Assert.IsTrue(messageContext.Headers.ContainsKey(expectedHeaderKey), "MessageContext should expose the custom header"); - Assert.AreEqual(expectedHeaderValue, messageContext.Headers[expectedHeaderKey], "MessageContext should expose the correct header value"); + Assert.That(messageContext, Is.Not.Null, "MessageContext should not be null"); + Assert.That(messageContext!.NativeMessageId, Is.EqualTo(expectedMessageId), "MessageContext should expose the native message id"); + Assert.That(messageContext.Headers.ContainsKey(expectedHeaderKey), Is.True, "MessageContext should expose the custom header"); + Assert.That(messageContext.Headers[expectedHeaderKey], Is.EqualTo(expectedHeaderValue), "MessageContext should expose the correct header value"); Assert.That(messageContext.Body.ToArray(), Is.EqualTo(expectedBody).AsCollection, "MessageContext should expose the correct message body"); - Assert.IsTrue(messageContext.Headers.ContainsKey(Headers.CorrelationId), "Native CorrelationId should be upconverted to the CorrelationId header"); - Assert.AreEqual(expectedCorrelationId, messageContext.Headers[Headers.CorrelationId], "Headers should expose the correct CorrelationId value"); - Assert.IsTrue(messageContext.Headers.ContainsKey(Headers.ReplyToAddress), "Native ReplyTo should be upconverted to the ReplyToAddress header"); - Assert.AreEqual(expectedReplyTo, messageContext.Headers[Headers.ReplyToAddress], "Headers should expose the correct ReplyTo header value"); - Assert.IsTrue(messageContext.Headers.ContainsKey(Headers.ContentType), "Native ContentType should be upconverted to the ContentType header"); - Assert.AreEqual(expectedContentType, messageContext.Headers[Headers.ContentType], "Headers should expose the correct ContentType header value"); + Assert.That(messageContext.Headers.ContainsKey(Headers.CorrelationId), Is.True, "Native CorrelationId should be upconverted to the CorrelationId header"); + Assert.That(messageContext.Headers[Headers.CorrelationId], Is.EqualTo(expectedCorrelationId), "Headers should expose the correct CorrelationId value"); + Assert.That(messageContext.Headers.ContainsKey(Headers.ReplyToAddress), Is.True, "Native ReplyTo should be upconverted to the ReplyToAddress header"); + Assert.That(messageContext.Headers[Headers.ReplyToAddress], Is.EqualTo(expectedReplyTo), "Headers should expose the correct ReplyTo header value"); + Assert.That(messageContext.Headers.ContainsKey(Headers.ContentType), Is.True, "Native ContentType should be upconverted to the ContentType header"); + Assert.That(messageContext.Headers[Headers.ContentType], Is.EqualTo(expectedContentType), "Headers should expose the correct ContentType header value"); } } @@ -91,8 +91,8 @@ public async Task Should_use_auto_generated_message_id_if_present() }); using (Assert.EnterMultipleScope()) { - Assert.True(result.MessageActions.WasCompleted, "Message should be completed"); - Assert.AreEqual(messageId, autoGeneratedMessageId, "Auto generated message id should used as message id"); + Assert.That(result.MessageActions.WasCompleted, Is.True, "Message should be completed"); + Assert.That(messageId, Is.EqualTo(autoGeneratedMessageId), "Auto generated message id should used as message id"); } } @@ -109,8 +109,8 @@ public async Task Should_complete_messages_with_sequence_number_derived_id_immed using (Assert.EnterMultipleScope()) { - Assert.IsTrue(firstProcessingResult.OnMessageWasCalled, "OnMessage should be called"); - Assert.IsTrue(firstProcessingResult.MessageActions.WasCompleted, "Message should be completed"); + Assert.That(firstProcessingResult.OnMessageWasCalled, Is.True, "OnMessage should be called"); + Assert.That(firstProcessingResult.MessageActions.WasCompleted, Is.True, "Message should be completed"); } var secondProcessingResult = await ProcessMessage( @@ -119,8 +119,8 @@ public async Task Should_complete_messages_with_sequence_number_derived_id_immed using (Assert.EnterMultipleScope()) { - Assert.IsFalse(secondProcessingResult.OnMessageWasCalled, "OnMessage should not be called on second delivery"); - Assert.IsTrue(secondProcessingResult.MessageActions.WasCompleted, "Message should be completed on second delivery"); + Assert.That(secondProcessingResult.OnMessageWasCalled, Is.False, "OnMessage should not be called on second delivery"); + Assert.That(secondProcessingResult.MessageActions.WasCompleted, Is.True, "Message should be completed on second delivery"); } } @@ -137,8 +137,8 @@ public async Task Should_complete_messages_immediately_if_previous_processing_at using (Assert.EnterMultipleScope()) { - Assert.IsTrue(firstProcessingResult.OnMessageWasCalled, "OnMessage should be called"); - Assert.IsTrue(firstProcessingResult.MessageActions.WasCompleted, "Message should be completed"); + Assert.That(firstProcessingResult.OnMessageWasCalled, Is.True, "OnMessage should be called"); + Assert.That(firstProcessingResult.MessageActions.WasCompleted, Is.True, "Message should be completed"); } var secondProcessingResult = await ProcessMessage( @@ -147,8 +147,8 @@ public async Task Should_complete_messages_immediately_if_previous_processing_at using (Assert.EnterMultipleScope()) { - Assert.IsFalse(secondProcessingResult.OnMessageWasCalled, "OnMessage should not be called"); - Assert.IsTrue(secondProcessingResult.MessageActions.WasCompleted, "Message should be completed"); + Assert.That(secondProcessingResult.OnMessageWasCalled, Is.False, "OnMessage should not be called"); + Assert.That(secondProcessingResult.MessageActions.WasCompleted, Is.True, "Message should be completed"); } } @@ -160,10 +160,10 @@ public async Task Should_complete_when_on_message_succeeds() using (Assert.EnterMultipleScope()) { - Assert.IsTrue(result.OnMessageWasCalled, "OnMessage should be called"); - Assert.IsFalse(result.OnErrorWasCalled, "OnError should not be called"); - Assert.IsTrue(result.MessageActions.WasCompleted, "Message should be completed"); - Assert.IsFalse(result.MessageActions.WasAbandoned, "Message should not be abandoned"); + Assert.That(result.OnMessageWasCalled, Is.True, "OnMessage should be called"); + Assert.That(result.OnErrorWasCalled, Is.False, "OnError should not be called"); + Assert.That(result.MessageActions.WasCompleted, Is.True, "Message should be completed"); + Assert.That(result.MessageActions.WasAbandoned, Is.False, "Message should not be abandoned"); } } @@ -176,10 +176,10 @@ public async Task Should_abandon_when_on_message_fails_and_retry_is_requested() using (Assert.EnterMultipleScope()) { - Assert.IsTrue(result.OnMessageWasCalled, "OnMessage should be called"); - Assert.IsTrue(result.OnErrorWasCalled, "OnError should be called"); - Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); - Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned"); + Assert.That(result.OnMessageWasCalled, Is.True, "OnMessage should be called"); + Assert.That(result.OnErrorWasCalled, Is.True, "OnError should be called"); + Assert.That(result.MessageActions.WasCompleted, Is.False, "Message should not be completed"); + Assert.That(result.MessageActions.WasAbandoned, Is.True, "Message should be abandoned"); } } @@ -192,10 +192,10 @@ public async Task Should_complete_when_on_message_fails_and_failure_is_marked_as using (Assert.EnterMultipleScope()) { - Assert.IsTrue(result.OnMessageWasCalled, "OnMessage should be called"); - Assert.IsTrue(result.OnErrorWasCalled, "OnError should be called"); - Assert.IsTrue(result.MessageActions.WasCompleted, "Message should be completed"); - Assert.IsFalse(result.MessageActions.WasAbandoned, "Message should not be abandoned"); + Assert.That(result.OnMessageWasCalled, Is.True, "OnMessage should be called"); + Assert.That(result.OnErrorWasCalled, Is.True, "OnError should be called"); + Assert.That(result.MessageActions.WasCompleted, Is.True, "Message should be completed"); + Assert.That(result.MessageActions.WasAbandoned, Is.False, "Message should not be abandoned"); } } @@ -208,9 +208,9 @@ public async Task Should_abandon_when_on_error_throws_transient_service_bus_exce using (Assert.EnterMultipleScope()) { - Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); - Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned if onError throws"); - Assert.IsFalse(result.MessageActions.WasDeadLettered, "Message should not be dead lettered"); + Assert.That(result.MessageActions.WasCompleted, Is.False, "Message should not be completed"); + Assert.That(result.MessageActions.WasAbandoned, Is.True, "Message should be abandoned if onError throws"); + Assert.That(result.MessageActions.WasDeadLettered, Is.False, "Message should not be dead lettered"); } } @@ -223,9 +223,9 @@ public async Task Should_abandon_when_on_error_throws_lock_lost_service_bus_exce using (Assert.EnterMultipleScope()) { - Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); - Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned if onError throws"); - Assert.IsFalse(result.MessageActions.WasDeadLettered, "Message should not be dead lettered"); + Assert.That(result.MessageActions.WasCompleted, Is.False, "Message should not be completed"); + Assert.That(result.MessageActions.WasAbandoned, Is.True, "Message should be abandoned if onError throws"); + Assert.That(result.MessageActions.WasDeadLettered, Is.False, "Message should not be dead lettered"); } } @@ -239,9 +239,9 @@ public async Task Should_dlq_when_on_error_throws_non_transient_exception() using (Assert.EnterMultipleScope()) { - Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); - Assert.IsFalse(result.MessageActions.WasAbandoned, "Message should not be abandoned if onError throws"); - Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); + Assert.That(result.MessageActions.WasCompleted, Is.False, "Message should not be completed"); + Assert.That(result.MessageActions.WasAbandoned, Is.False, "Message should not be abandoned if onError throws"); + Assert.That(result.MessageActions.WasDeadLettered, Is.True, "Message should be dead lettered"); } } @@ -261,12 +261,12 @@ public async Task Should_dlq_message_if_requested() using (Assert.EnterMultipleScope()) { - Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); - Assert.IsFalse(result.MessageActions.WasAbandoned, "Message should not be abandoned"); - Assert.IsTrue(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Error, "DLQ requests should be logged as error"); - Assert.True(result.LogCollector.LatestRecord.Message.Contains(expectedDlqReason), "Should log DLQ reason"); - Assert.True(result.LogCollector.LatestRecord.Message.Contains(expectedDlqDescription), "Should log DLQ description"); + Assert.That(result.MessageActions.WasCompleted, Is.False, "Message should not be completed"); + Assert.That(result.MessageActions.WasAbandoned, Is.False, "Message should not be abandoned"); + Assert.That(result.MessageActions.WasDeadLettered, Is.True, "Message should be dead lettered"); + Assert.That(result.LogCollector.LatestRecord.Level, Is.EqualTo(Microsoft.Extensions.Logging.LogLevel.Error), "DLQ requests should be logged as error"); + Assert.That(result.LogCollector.LatestRecord.Message, Does.Contain(expectedDlqReason), "Should log DLQ reason"); + Assert.That(result.LogCollector.LatestRecord.Message, Does.Contain(expectedDlqDescription), "Should log DLQ description"); } } @@ -281,8 +281,8 @@ public async Task Should_expose_the_service_bus_message_on_both_message_and_erro using (Assert.EnterMultipleScope()) { - Assert.AreSame(message, result.MessageContext?.Extensions.Get(), "MessageContext should contain the ServiceBusReceivedMessage"); - Assert.AreSame(message, result.ErrorContext?.Extensions.Get(), "ErrorContext should contain the ServiceBusReceivedMessage"); + Assert.That(result.MessageContext?.Extensions.Get(), Is.SameAs(message), "MessageContext should contain the ServiceBusReceivedMessage"); + Assert.That(result.ErrorContext?.Extensions.Get(), Is.SameAs(message), "ErrorContext should contain the ServiceBusReceivedMessage"); } } @@ -300,12 +300,12 @@ public async Task Should_abandon_when_token_is_cancelled_and_not_invoke_onerror( using (Assert.EnterMultipleScope()) { - Assert.IsFalse(result.OnErrorWasCalled, "OnError should not be called"); - Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); - Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned"); - Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Debug, "Cancellation should be logged as debug"); - Assert.True(result.LogCollector.LatestRecord.Message.Contains("Message processing canceled"), "Should log debug when processing canceled"); - Assert.IsInstanceOf(result.LogCollector.LatestRecord.Exception); + Assert.That(result.OnErrorWasCalled, Is.False, "OnError should not be called"); + Assert.That(result.MessageActions.WasCompleted, Is.False, "Message should not be completed"); + Assert.That(result.MessageActions.WasAbandoned, Is.True, "Message should be abandoned"); + Assert.That(result.LogCollector.LatestRecord.Level, Is.EqualTo(Microsoft.Extensions.Logging.LogLevel.Debug), "Cancellation should be logged as debug"); + Assert.That(result.LogCollector.LatestRecord.Message, Does.Contain("Message processing canceled"), "Should log debug when processing canceled"); + Assert.That(result.LogCollector.LatestRecord.Exception, Is.InstanceOf()); } } @@ -324,11 +324,11 @@ public async Task Should_abandon_on_error_when_token_is_cancelled() using (Assert.EnterMultipleScope()) { - Assert.IsFalse(result.MessageActions.WasCompleted, "Message should not be completed"); - Assert.IsTrue(result.MessageActions.WasAbandoned, "Message should be abandoned"); - Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Debug, "Cancellation should be logged as debug"); - Assert.True(result.LogCollector.LatestRecord.Message.Contains("OnError canceled"), "Should log debug when on error canceled"); - Assert.IsInstanceOf(result.LogCollector.LatestRecord.Exception); + Assert.That(result.MessageActions.WasCompleted, Is.False, "Message should not be completed"); + Assert.That(result.MessageActions.WasAbandoned, Is.True, "Message should be abandoned"); + Assert.That(result.LogCollector.LatestRecord.Level, Is.EqualTo(Microsoft.Extensions.Logging.LogLevel.Debug), "Cancellation should be logged as debug"); + Assert.That(result.LogCollector.LatestRecord.Message, Does.Contain("OnError canceled"), "Should log debug when on error canceled"); + Assert.That(result.LogCollector.LatestRecord.Exception, Is.InstanceOf()); } } @@ -357,9 +357,9 @@ public async Task Should_not_propagate_header_mutations_from_on_message_to_on_er var headers = result.ErrorContext!.Message.Headers; using (Assert.EnterMultipleScope()) { - Assert.IsTrue(headers.ContainsKey(originalHeaderKey), "Original header should still exist in onError"); - Assert.AreEqual(originalHeaderValue, headers[originalHeaderKey], "Original header value should be preserved in onError"); - Assert.IsFalse(headers.ContainsKey(addedHeaderKey), "Added header should NOT be present in onError"); + Assert.That(headers.ContainsKey(originalHeaderKey), Is.True, "Original header should still exist in onError"); + Assert.That(headers[originalHeaderKey], Is.EqualTo(originalHeaderValue), "Original header value should be preserved in onError"); + Assert.That(headers.ContainsKey(addedHeaderKey), Is.False, "Added header should NOT be present in onError"); } } @@ -371,9 +371,9 @@ public async Task Should_dead_letter_if_header_extraction_fails() using (Assert.EnterMultipleScope()) { - Assert.False(result.OnMessageWasCalled, "OnMessage should not be called if header extraction fails"); - Assert.False(result.OnErrorWasCalled, "OnError should not be called if header extraction fails"); - Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); + Assert.That(result.OnMessageWasCalled, Is.False, "OnMessage should not be called if header extraction fails"); + Assert.That(result.OnErrorWasCalled, Is.False, "OnError should not be called if header extraction fails"); + Assert.That(result.MessageActions.WasDeadLettered, Is.True, "Message should be dead lettered"); } } @@ -386,9 +386,9 @@ public async Task Show_log_and_swallow_exceptions_from_dead_lettering_unless_inv using (Assert.EnterMultipleScope()) { - Assert.True(result.MessageActions.WasDeadLettered, "Message should be dead lettered"); - Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Warning, "Should be logged as warning"); - Assert.AreSame(result.LogCollector.LatestRecord.Exception, exception); + Assert.That(result.MessageActions.WasDeadLettered, Is.True, "Message should be dead lettered"); + Assert.That(result.LogCollector.LatestRecord.Level, Is.EqualTo(Microsoft.Extensions.Logging.LogLevel.Warning), "Should be logged as warning"); + Assert.That(result.LogCollector.LatestRecord.Exception, Is.SameAs(exception)); } } @@ -405,9 +405,9 @@ public async Task Show_log_and_swallow_exceptions_from_abandon_unless_invocation using (Assert.EnterMultipleScope()) { - Assert.True(result.MessageActions.WasAbandoned, "Message should be abandoned"); - Assert.AreEqual(result.LogCollector.LatestRecord.Level, Microsoft.Extensions.Logging.LogLevel.Warning, "Should be logged as warning"); - Assert.AreSame(result.LogCollector.LatestRecord.Exception, exception); + Assert.That(result.MessageActions.WasAbandoned, Is.True, "Message should be abandoned"); + Assert.That(result.LogCollector.LatestRecord.Level, Is.EqualTo(Microsoft.Extensions.Logging.LogLevel.Warning), "Should be logged as warning"); + Assert.That(result.LogCollector.LatestRecord.Exception, Is.SameAs(exception)); } } @@ -498,4 +498,4 @@ class FakeBaseReceiver : IMessageReceiver public string Id => string.Empty; public string ReceiveAddress => "TestEndpoint"; } -} \ No newline at end of file +} From a9386166187cfac74ea41f6326a855431f4f8d56 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Mon, 30 Mar 2026 15:53:13 +0200 Subject: [PATCH 65/68] Remove extra whitespace in `[ServiceBusTrigger]` attribute for consistency. --- ...itionGeneratorTests.GeneratesProjectComposition.approved.txt | 2 +- src/Tests.Analyzers/TestSources.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt index fd15951..1644343 100644 --- a/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt @@ -15,7 +15,7 @@ public partial class Functions [NServiceBusFunction] [Function("ProcessOrder")] public partial Task Run( - [ServiceBusTrigger("sales-queue", Connection = "AzureServiceBus" , AutoCompleteMessages = false)] ServiceBusReceivedMessage message, + [ServiceBusTrigger("sales-queue", Connection = "AzureServiceBus", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, FunctionContext context, CancellationToken cancellationToken); diff --git a/src/Tests.Analyzers/TestSources.cs b/src/Tests.Analyzers/TestSources.cs index 64ad223..4f28f47 100644 --- a/src/Tests.Analyzers/TestSources.cs +++ b/src/Tests.Analyzers/TestSources.cs @@ -10,7 +10,7 @@ public partial class Functions [NServiceBusFunction] [Function("ProcessOrder")] public partial Task Run( - [ServiceBusTrigger("sales-queue", Connection = "AzureServiceBus" , AutoCompleteMessages = false)] ServiceBusReceivedMessage message, + [ServiceBusTrigger("sales-queue", Connection = "AzureServiceBus", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, FunctionContext context, CancellationToken cancellationToken); From d6c66a5292e4d5769551ad8f1ab87bb4c0f46c1e Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Mon, 30 Mar 2026 15:58:51 +0200 Subject: [PATCH 66/68] Refactor `PipelineInvokingMessageProcessor` to use primary constructor, simplify initialization, and declare `GetNServiceBusHeaders` as static. --- .../PipelineInvokingMessageProcessor.cs | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs index 49a2fce..cb1ce08 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/PipelineInvokingMessageProcessor.cs @@ -12,21 +12,13 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus; using NServiceBus.Transport.AzureServiceBus; using static PipelineInvokingMessageProcessorLog; -class PipelineInvokingMessageProcessor : IMessageReceiver +class PipelineInvokingMessageProcessor( + IMessageReceiver baseTransportReceiver, + ICache messagesToBeCompleted, + ILogger logger, + Func>? headerExtractor = null) + : IMessageReceiver { - public PipelineInvokingMessageProcessor(IMessageReceiver baseTransportReceiver, - ICache messagesToBeCompleted, - ILogger logger, - Func>? headerExtractor = null) - { - this.baseTransportReceiver = baseTransportReceiver; - this.messagesToBeCompleted = messagesToBeCompleted; - this.logger = logger; - - // we do this to enable tests to simulate exceptions when extracting headers - extractHeaders = headerExtractor ?? GetNServiceBusHeaders; - } - public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnError onError, CancellationToken cancellationToken = default) { @@ -83,7 +75,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc // we need to clone the headers since the core pipeline might mutate them var messageContext = new MessageContext(nativeMessageId, new Dictionary(headers), body, azureServiceBusTransportTransaction.TransportTransaction, ReceiveAddress, contextBag); - await onMessage!(messageContext, cancellationToken).ConfigureAwait(false); + await onMessage(messageContext, cancellationToken).ConfigureAwait(false); azureServiceBusTransportTransaction.Commit(); @@ -102,7 +94,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc { // No need to clone the message header here since we do not make use of them after on error has executed var errorContext = new ErrorContext(exception, headers, nativeMessageId, body, azureServiceBusTransportTransaction.TransportTransaction, message.DeliveryCount, ReceiveAddress, contextBag); - errorHandleResult = await onError!.Invoke(errorContext, cancellationToken).ConfigureAwait(false); + errorHandleResult = await onError.Invoke(errorContext, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { @@ -144,7 +136,7 @@ public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageAc } } - Dictionary GetNServiceBusHeaders(ServiceBusReceivedMessage message) + static Dictionary GetNServiceBusHeaders(ServiceBusReceivedMessage message) { var headers = new Dictionary(message.ApplicationProperties.Count); @@ -229,13 +221,11 @@ async Task SafeCompleteMessage(ServiceBusMessageActions messageActions, string n public string Id => baseTransportReceiver.Id; public string ReceiveAddress => baseTransportReceiver.ReceiveAddress; - OnMessage? onMessage; - OnError? onError; + OnMessage onMessage = static (_, _) => Task.CompletedTask; + OnError onError = static (_, _) => Task.FromResult(ErrorHandleResult.Handled); - readonly IMessageReceiver baseTransportReceiver; - readonly ICache messagesToBeCompleted; - readonly ILogger logger; - readonly Func> extractHeaders; + // we do this to enable tests to simulate exceptions when extracting headers + readonly Func> extractHeaders = headerExtractor ?? GetNServiceBusHeaders; } static partial class PipelineInvokingMessageProcessorLog From a28cdc2008cb696854a63d25a5822356e009a1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 31 Mar 2026 07:47:52 +0200 Subject: [PATCH 67/68] Provide dead letter API as an extension of RecoverabilityActions to align with core --- src/IntegrationTest.Sales/SalesEndpoint.cs | 2 +- .../DeadLetterMessage.cs | 6 +++--- .../RecoverabilityActionExtensions.cs | 12 ++++++++++++ ...s.ApprovaAzureServiceBusComponentApi.approved.txt | 12 +++++++++--- 4 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 src/NServiceBus.AzureFunctions.AzureServiceBus/RecoverabilityActionExtensions.cs diff --git a/src/IntegrationTest.Sales/SalesEndpoint.cs b/src/IntegrationTest.Sales/SalesEndpoint.cs index d8321a2..2bb69db 100644 --- a/src/IntegrationTest.Sales/SalesEndpoint.cs +++ b/src/IntegrationTest.Sales/SalesEndpoint.cs @@ -34,7 +34,7 @@ public static void ConfigureSales(EndpointConfiguration configuration) return RecoverabilityAction.ImmediateRetry(); } - return new DeadLetterMessage(context.Exception); + return RecoverabilityAction.DeadLetter(context.Exception); }); } } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs index 53ec5f6..6594ed6 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterMessage.cs @@ -3,12 +3,12 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus; using Pipeline; using Transport; -public class DeadLetterMessage : RecoverabilityAction +public sealed class DeadLetterMessage : RecoverabilityAction { - public DeadLetterMessage(string deadLetterReason, string deadLetterErrorDescription, Dictionary? propertiesToModify = null) => + internal DeadLetterMessage(string deadLetterReason, string deadLetterErrorDescription, Dictionary? propertiesToModify = null) => deadLetterRequest = new DeadLetterRequest(deadLetterReason, deadLetterErrorDescription, propertiesToModify); - public DeadLetterMessage(Exception exception) => + internal DeadLetterMessage(Exception exception) => deadLetterRequest = new DeadLetterRequest(exception); public override IReadOnlyCollection GetRoutingContexts(IRecoverabilityActionContext context) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/RecoverabilityActionExtensions.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/RecoverabilityActionExtensions.cs new file mode 100644 index 0000000..07393b3 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/RecoverabilityActionExtensions.cs @@ -0,0 +1,12 @@ +namespace NServiceBus.AzureFunctions.AzureServiceBus; + +public static class RecoverabilityActionExtensions +{ + extension(RecoverabilityAction _) + { + public static DeadLetterMessage DeadLetter(string deadLetterReason, string deadLetterErrorDescription, Dictionary? propertiesToModify = null) + => new(deadLetterReason, deadLetterErrorDescription, propertiesToModify); + + public static DeadLetterMessage DeadLetter(Exception exception) => new(exception); + } +} \ No newline at end of file diff --git a/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt b/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt index 0697282..bdddbc5 100644 --- a/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt +++ b/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt @@ -6,13 +6,19 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus public AzureServiceBusMessageProcessor(NServiceBus.AzureServiceBusServerlessTransport transport, string endpointName) { } public System.Threading.Tasks.Task Process(Azure.Messaging.ServiceBus.ServiceBusReceivedMessage message, Microsoft.Azure.Functions.Worker.ServiceBusMessageActions messageActions, Microsoft.Azure.Functions.Worker.FunctionContext functionContext, System.Threading.CancellationToken cancellationToken = default) { } } - public class DeadLetterMessage : NServiceBus.RecoverabilityAction + public sealed class DeadLetterMessage : NServiceBus.RecoverabilityAction { - public DeadLetterMessage(System.Exception exception) { } - public DeadLetterMessage(string deadLetterReason, string deadLetterErrorDescription, System.Collections.Generic.Dictionary? propertiesToModify = null) { } public override NServiceBus.Transport.ErrorHandleResult ErrorHandleResult { get; } public override System.Collections.Generic.IReadOnlyCollection GetRoutingContexts(NServiceBus.Pipeline.IRecoverabilityActionContext context) { } } + public static class RecoverabilityActionExtensions + { + extension(NServiceBus.RecoverabilityAction _) + { + public static NServiceBus.AzureFunctions.AzureServiceBus.DeadLetterMessage DeadLetter(string deadLetterReason, string deadLetterErrorDescription, System.Collections.Generic.Dictionary? propertiesToModify = null) { } + public static NServiceBus.AzureFunctions.AzureServiceBus.DeadLetterMessage DeadLetter(System.Exception exception) { } + } + } } namespace NServiceBus { From f49ae61098ba2a1803d4e470365304b786d77d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20=C3=96hlund?= Date: Tue, 31 Mar 2026 07:49:45 +0200 Subject: [PATCH 68/68] Formatting --- .../DeadLetterRequest.cs | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs index ffa2486..b6644eb 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/DeadLetterRequest.cs @@ -1,17 +1,10 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus; -class DeadLetterRequest +class DeadLetterRequest(string deadLetterReason, string deadLetterErrorDescription, Dictionary? propertiesToModify = null) { - public string DeadLetterReason { get; } - public string DeadLetterErrorDescription { get; } - public Dictionary PropertiesToModify { get; } - - public DeadLetterRequest(string deadLetterReason, string deadLetterErrorDescription, Dictionary? propertiesToModify = null) - { - DeadLetterReason = Truncate(deadLetterReason, 1024); - DeadLetterErrorDescription = Truncate(deadLetterErrorDescription, 1024); - PropertiesToModify = propertiesToModify ?? []; - } + public string DeadLetterReason { get; } = Truncate(deadLetterReason, 1024); + public string DeadLetterErrorDescription { get; } = Truncate(deadLetterErrorDescription, 1024); + public Dictionary PropertiesToModify { get; } = propertiesToModify ?? []; public DeadLetterRequest(Exception exception, Dictionary? propertiesToModify = null) : this( $"{exception.GetType().FullName} - {exception.Message}", @@ -20,10 +13,5 @@ public DeadLetterRequest(Exception exception, Dictionary? proper { } - static string Truncate(string value, int maxLength) => - string.IsNullOrEmpty(value) - ? value - : value.Length <= maxLength - ? value - : value[..maxLength]; + static string Truncate(string value, int maxLength) => string.IsNullOrEmpty(value) ? value : value.Length <= maxLength ? value : value[..maxLength]; } \ No newline at end of file