Location>code7788 >text

Transactional Outbox Patterns for .NET Cloud Native Development (based on Aspire)

Popularity:945 ℃/2024-09-07 15:25:45

Original text:Transactional Outbox in .NET Cloud Native Development via Aspire
Author:Oleksii Nikiforov

a general overview

This article provides information on how to use theAspireAzure Service BusAzure SQLBicep cap (a poem)azd Example of implementing Outbox mode.

Source Code:/NikiforovAll/cap-aspire

Introduction to Outbox Mode

Outbox Modeis an important component in the field of distributed systems. As modern software development moves towards more distributed and decoupled architectures, ensuring reliable messaging becomes increasingly important.

In a distributed system, different components need to communicate with each other, usually through asynchronous messaging.Outbox ModeProvides a reliable way to handle these messages. It ensures that even if the system fails after executing a local transaction but before sending the message, the message will not be lost. Instead, it is temporarily stored in the Outbox and retrieved and sent when the system recovers.

By using theOutbox ModeWe can ensure that all components of the system receive the necessary messages in a reliable manner, thus ensuring the integrity and consistency of the entire system.

in the absence ofOutbox Modeof distributed systems, there are several scenarios in which errors can occur that can lead to data inconsistencies or lost messages. Here are a few examples:

  1. Transaction commits and message sending are not atomic: In the usual case, a Service may first commit a transaction to its database and then send a message to a message broker (Broker). If the Service crashes after the transaction is committed but before the message is sent, the message will be lost. Other Services will be unaware of the changes that have been committed to the database.
  2. Failed to send message: Even if the service has not crashed, message sending may fail due to network problems or message broker problems. If the message sending operation is not retried, the message will be lost.
  3. duplicate message: If a service retries a message sending operation after a failure, it may end up sending the same message multiple times if the first send actually succeeds but the acknowledgment is lost. This may lead to duplicate processing if the message user is not idempotent.
  4. Problems with sequencing: If a single transaction sends multiple messages and these sends are not atomic, the messages may be received out of order. This may lead to incorrect processing if the order of the messages is important.

Outbox ModeThese problems are addressed by ensuring that transaction commits and message sending operations are atomic, and by providing mechanisms to reliably send messages even in the event of a failure.

Below is a sequence diagram that illustrates the problems with systems that do not have an outbox model:

Power consumersplays an important role in the outbox model. In the context of distributed systems, idempotency is the ability of a system to produce the same result no matter how many times a particular operation is performed. This is essential to ensure data consistency and reliability in a distributed environment.

However, this can lead to the same message being sent multiple times, especially if the system fails after sending the message but before marking it as sent in the outbox. This is where idempotent consumers come into play.

Power consumersDesigned to handle duplicate messages properly. They ensure that the side effect of receiving the same message multiple times is eliminated. This is usually accomplished by keeping track of the IDs of all processed messages. When a message is received, the consumer checks to see if it has already processed a message with the same ID. If it has, it ignores the message.

Below is a sequence diagram that illustrates how the outbox mode solves the problem:

Implementation of the outbox model

Now that you understand the importance and benefits of the Outbox pattern, let's dive into what it takes to implement it:

The implementation of the Outbox mode involves the following steps:

  1. Creating an Outbox Table: The first step is to create the Outbox table in the database. This table will store all the messages that need to be sent. Each message should have a unique ID and a status field indicating whether the message has been sent or not.
  2. Modify the application code: The next step is to modify the application code. Whenever your application needs to send a message as part of a transaction, it should add the message to the Outbox table as part of the same transaction.
  3. Implementation of the outbox publisher: The outbox publisher is a separate component that polls the outbox table for unsent messages. When it finds an unsent message, it sends it and updates the status of the message in the Outbox table to "Sent".

synopsis

Fortunately, there is a program called NET library can simplify the implementation of Outbox mode for us.

is an open source library that provides a set of APIs that allow developers to easily send messages as part of a database transaction, store them in the outbox, and ensure that they are reliably delivered to all interested consumers even in the event of a failure.

The library also supports idempotent consumers, which are critical for ensuring data consistency and reliability in distributed environments. This means that the side effects of receiving the same message are eliminated even if the same message is delivered multiple times.

By using the, developers can focus on the business logic of their applications while libraries take care of the complexity of ensuring reliable messaging in distributed systems.

(for) instance

This code demonstrates how to use the CAP library for event posting and handling in a Core application.

Among the producers:

  • Defines the route handler for the "/send" endpoint.
  • It starts a transaction, executes an SQL command to get the current server time, and posts a message with that time to the "" topic.
  • The message is posted with a delay of 500 milliseconds.
  • If all operations succeed, the transaction is committed and the response is returned.
// Producer/  
("/send", async (  
	SqlConnection connection,  
	ICapPublisher capPublisher,  
	TimeProvider timeProvider) =>  
{  
	var startTime = ();  
	using var transaction = connection  
	.BeginTransaction(capPublisher, autoCommit: false);

	var command = ();
	 = (SqlTransaction)transaction;
	 = "SELECT GETDATE()";
	var serverTime = await ();

	await (
		(500),
		"",
		new MyMessage(serverTime?.ToString()!));

	();

	return (new
	{
		Status = "Published",
		Duration = (startTime)
	});
});  

💡Note.BeginTransaction Extension methods are defined in theThe one in the center is responsible for the management of the outgoing mailbox table.

public static IDbTransaction BeginTransaction(  
	this IDbConnection dbConnection,  
	ICapPublisher publisher,  
	bool autoCommit = false)  

On the consumer side:

  • Define an implementationICapSubscribeclassSampleSubscriber
  • It has aHandleAsyncThe method that willCapSubscribe Specify "" as the subject's handler.
  • When a message is received on this topic, it logs the contents of the message after a delay of 300 milliseconds.
// Consumer/  
public class SampleSubscriber(  
	TimeProvider timeProvider,  
	ILogger <samplesubscriber>logger) : ICapSubscribe  
{  
	public record MyMessage(string CreatedAt);
	
	[CapSubscribe("")]
	public async Task HandleAsync(MyMessage message)
	{
		await ((300), timeProvider);

		("Message received: {CreatedAt}", );
	}
}  

Add Aspire

For a complete demonstration of the demo, we need to set up some real infrastructure components - the message broker and the database.

NET Aspire provides a select set of NuGet packages (components) specifically designed to facilitate the integration of cloud-native applications. Each component provides the necessary cloud-native functionality through auto-configuration or standardized configuration modes.

Add a message broker:

NET Aspire Service Bus component handles the following to connect your application to the Azure Service Bus. It addsServiceBusClient to the DI container to connect to the Azure Service Bus.

dotnet add package  --prerelease  

Add database:

NET Aspire provides two built-in configuration options to simplify SQL Server deployment on Azure:

  1. Preconfiguring a Containerized SQL Server Database with Azure Container Apps
  2. Provide an Azure SQL database instance (we will use this)
dotnet add package  --prerelease  

Here's how we set up Aspire Host based on the installed components:

// /  
var builder = (args);

var sqlServer =   
	? ("sqlserver").PublishAsAzureSqlDatabase().AddDatabase("sqldb")  
	: ("sqldb");

var serviceBus =   
	? ("serviceBus")  
	: ("serviceBus");

&lt;&gt;("consumer")  
	.WithReference(sqlServer)  
	.WithReference(serviceBus);

&lt;&gt;("producer")  
	.WithReference(sqlServer)  
	.WithReference(serviceBus);

().Run();  

The idea is to use connection strings at development time and configure Azure resources at release time.

Aspire provides a flexible configuration system that allows us to develop locally and deploy to the cloud without changing the source code. We can use connection strings managed by Aspire Components that can be easily switched between local development and cloud deployment environments. This allows us to seamlessly transition between different deployment scenarios without having to change the source code.

Below you can find how to configure based on Aspire components

// Consumer/  
// Producer/  
var builder = (args);

();  
("serviceBus");  
("sqldb");

(x =>  
{  
	var dbConnectionString = ("sqldb")!;  
	var serviceBusConnection = ("serviceBus")!;

	(serviceBusConnection);
	(x =>  = dbConnectionString);
});  

Provision of infrastructure

Azure Developer CLI (azd) has been enhanced to support deployment of .NET Aspire applications.azd initWorkflow provides customized support for .NET Aspire projects. I used this approach while developing this application and it proved to be very good. It has increased productivity.

(coll.) fail (a student)azdWhen the target is a .NET Aspire application, it is started with the specified command (AppHost dotnet run --project -- --publisher manifest) to generate the Aspire manifest file.

azd provisionThe command logic interrogates the manifest file to generate the Bicep file in memory only (by default).

For more information see/en-us/dotnet/aspire/deployment/azure/aca-deployment-azd-in-depth?tabs=linux#how-azure-developer-cli-integration-works

Personally, I find it easier to explicitly generate Bicep files. This can be done by executing the following command:

❯ azd infra synth  

Below is a visualization of what will be configured:

In order to configure the resource, we need to run the next command:

❯ azd provision  

The resource group created here-rg-cap-dev

Configuring Local Development

To retrieve the Azure Service Bus connection string:

#!/bin/bash
resourceGroup="rg-cap-dev"
namespace=$(
    az servicebus namespace list \
        --resource-group $resourceGroup \
        --output tsv \
        --query '[0].name'
)
azbConnectionString=$(
    az servicebus namespace authorization-rule keys list \
        --namespace-name "$namespace" \
        --name RootManageSharedAccessKey \
        --resource-group $resourceGroup \
        --output tsv \
        --query 'primaryConnectionString'
)

dotnet user-secrets --project ./src/ \
    set ConnectionStrings:serviceBus $azbConnectionString

To retrieve the connection string for the Azure SQL database:

#!/bin/bash

# read server address
if [ -f .azure/cap-dev/.env ]
then
    export $(cat .azure/cap-dev/.env | sed 's/#.*//g' | xargs)
fi

# read server password
db_password=$(jq -r '.' .azure/cap-dev/)

dotnet user-secrets --project ./src/ set ConnectionStrings:sqldb \
    "Server=$SQLSERVER_SQLSERVERFQDN;Initial Catalog=sqldb;Persist Security Info=False;User ID=CloudSA;Password=$db_password;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;"

To check if the confidentiality is stored successfully:

❯ dotnet user-secrets --project ./src/ list

# ConnectionStrings:sqldb = Server=;Initial Catalog=sqldb;Persist Security Info=False;User ID=CloudSA;Password=<your-password>;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;
# ConnectionStrings:serviceBus = Endpoint=sb:///;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=<your-key>

demonstrations

As you may know, Aspire has out-of-the-box support for OpenTelemetry, which you can configure in ServiceDefaults/.

There is a package that supports OpenTelemetry functionality.

Distributed spanning in OpenTelemetry helps you track operations on a message broker by providing a way to track message flow across different components of the system.

When using a message broker such as this one, messages are typically sent from the producer to the consumer through an intermediate broker. Each step in the process can be thought of as a span, which represents a unit of work or an operation.

By using OpenTelemetry inspection code, you can create spans that capture information about each step of the message flow. These spans can include detailed information such as the time spent, any errors encountered, and other contextual information.

With Distributed Span, you can visualize the entire process of a message moving through the system, from producer to agent and finally to consumer. This gives you insight into the performance and behavior of the message processing pipeline.

By analyzing distributed spans, you can identify bottlenecks, latency issues, and potential errors in the message processing flow. This information is valuable for troubleshooting distributed systems and optimizing their performance.

following What you need to do to install and configure OpenTelemetry :

Install the NuGet package:

❯ dotnet add ./src// package   

Adjust the default configuration:

()
    .WithMetrics(metrics =>
    {
        metrics
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddProcessInstrumentation()
            .AddRuntimeInstrumentation();
    })
    .WithTracing(tracing =>
    {
        if (())
        {
            (new AlwaysOnSampler());
        }

        tracing
            .AddAspNetCoreInstrumentation()
            .AddGrpcClientInstrumentation()
            .AddHttpClientInstrumentation()
            .AddCapInstrumentation(); // <-- add this code
    });

this locality

Now, let's run the demo:

❯ dotnet run --project ./src/
# Building...
# Restore complete (0.6s)
#    succeeded (0.2s) → src\\bin\Debug\net9.0\
#   Consumer succeeded (0.3s) → src\Consumer\bin\Debug\net9.0\
#   Producer succeeded (0.5s) → src\Producer\bin\Debug\net9.0\
#    succeeded (0.2s) → src\\bin\Debug\net9.0\

# Build succeeded in 2.6s
# info: [0]
#       Aspire version: 9.0.0-preview.2.24162.2+eaca163f7737934020f1102a9d11fdf790cccdc0
# info: [0]
#       Distributed application starting.
# info: [0]
#       Application host directory is: C:\Users\Oleksii_Nikiforov\dev\cap-aspire\src\
# info: [0]
#       Now listening on: http://localhost:15118
# info: [0]
#       Distributed application started. Press Ctrl+C to shut down.

Let's generate some load:

❯ curl -s http://localhost:5288/send | jq
# {
#   "status": "Published",
#   "duration": "00:00:00.5255861"
# }

Navigate to the Aspire Dashboard to view the trace:

This is the first Request, and as you can see, it will take some time for us to establish an initial connection to the Azure Service Bus:

Follow-up requests take less time:

💡 I recommend that you delve into the source code and trace the examples to enhance your understanding of how Outbox Pattern works.

Azure

Let's do that by runningazd deployDeploying to Azure Container Applications

In the initial configuration (azd init) during which I specify the public address of the producer. We can utilize it for development testing:

Let's generate some load and look at Azure Service Bus metrics.

❯ curl -s /send | jq
# {
#   "status": "Published",
#   "duration": "00:00:00.0128251"
# }

outbox list

Create two tables in the backend to manage outboxes.

existOutbox ModeThe Published and Received tables are used to manage messages that need to be published or have been received by the messaging system. Let's take a closer look at the purpose of each table:

Published table: Responsible for storing messages that need to be published to an external messaging system. When the application generates a message that needs to be sent, the message is stored in the Published table. This table acts as a buffer or queue to ensure that messages are not lost if the messaging system is temporarily unavailable or if there are any failures during the publishing process. By using the Published table, the application can continue to generate messages without being hindered by the availability or performance of the messaging system.Messages in the Published table can be processed asynchronously by separate components or background processes that are responsible for publishing them to an external messaging system. Once a message has been successfully published, it can be removed from the Published table.

Received table: Used to track messages received by an application from an external messaging system. When an application receives a message, it stores the necessary information about the message in the Received table. This information can include message content, metadata, and any other relevant details.The Received table allows the application to keep a record of messages that have already been processed, thus enabling it to deal with duplicate messages.

clear up

After completing the development, you can delete the resource grouprg-cap-dev

❯ azd down
# Deleting all resources and deployed code on Azure (azd down)
# Local application code is not deleted when running 'azd down'.
#   Resource group(s) to be deleted:
#     • rg-cap-dev: /#@/resource/subscriptions/0b252e02-9c7a-4d73-8fbc-633c5d111ebc/resourceGroups/rg-cap-dev/overview
# ? Total resources to delete: 10, are you sure you want to continue? Yes
# Deleting your resources can take some time.
#   (✓) Done: Deleting resource group: rg-cap-dev

# SUCCESS: Your application was removed from Azure in 10 minutes 53 seconds.

summarize

In this paper, we explore theOutbox Mode, which is a key component in the field of distributed systems. We have learned how it ensures reliable message passing in distributed systems, thus maintaining the integrity and consistency of the entire system.

We also looked at how .NET libraries simplify the implementation of the Outbox Pattern, allowing developers to focus on the business logic of their applications while the libraries take care of the complexity of ensuring reliable messaging.

Finally, we saw how .NET Aspire offers a select suite of NuGet packages to facilitate the integration of cloud-native applications and provide the necessary cloud-native functionality through auto-configuration or standardized configuration modes.

In summary, the combination of Outbox Pattern, and .NET Aspire provides a powerful toolset for building reliable cloud-native applications in . By understanding and utilizing these tools, developers can build applications that are robust, scalable, and ready to meet the challenges of the modern distributed world.

consultation

/en-us/azure/developer/azure-developer-cli/
/
/en-us/dotnet/aspire/deployment/azure/aca-deployment-azd-in-deep