How to handle Idempotentcy in distributed system using asp.net core

7 minute read

Introduction

In a distributed application, idempotent operation or idempotency refers to the property of an operation such that multiple identical requests have the same effect as a single request. This is useful in situations where a request may be lost or a response may be delayed, as it allows the client to retry the request without worrying about producing unintended side effects.

For example, consider a distributed system that allows users to transfer money between accounts. If a client sends a request to transfer $100 from account A to account B, but the request is lost before it is received by the server, the client may not know whether the transfer was successful or not. If the transfer operation is idempotent, the client can simply retry the request, and the server will ensure that the transfer is performed only once, regardless of the number of times the request is received.

There are several approaches to solving idempotency in distributed systems:

1. Versioning: As mentioned earlier, versioning is a technique where the server stores a version number for each resource and only performs an operation if the version number has not changed since the client’s last request. This allows the server to detect and ignore duplicate requests.

2. Request IDs: Another approach is to assign a unique request ID to each request and store a record of all request IDs that have been processed by the server. If the server receives a request with a request ID that it has already processed, it can simply ignore the request.

3. Locking: In some cases, it may be necessary to use locks to ensure that only one instance of an operation is in progress at a time. For example, a distributed lock could be used to prevent multiple clients from concurrently transferring money from the same account.

4. State reconciliation: In a distributed system, it is possible for the state of a resource to become inconsistent due to lost or delayed requests. One way to solve this problem is to use state reconciliation, where the server periodically checks the state of all resources and reconciles any inconsistencies.

In this article I will show you how to handle idempotent operation using asp.net core. Here, I have created a request Id (idempotencyKey here) and check whether it is already exists within a time period.

Implementation:
Let’s implement idempotency using asp.net core.

Tools and Technologies used:

  • Visual Studio 2022
  • Visual C#
  • SQL Server
  • Redis
  • .NET 6.0
  • ASP.NET Core Web API

Step 1: Setup and run Redis in docker container.

  • Run Redis docker image using the following command.
docker run -d -p 6379:6379 --name myredis --network redisnet redis
  • You can use Redis desktop manager as client of Redis

Step 2: Create solution and project

  • Create a solution name IdempotentDemo using visual studio
  • Create a new web api project name Accounting.API

Step 3: Install nuget packages

  • Install following nuget packages in Accounting.API
PM> Install-Package Microsoft.EntityFrameworkCore
PM> Install-Package Microsoft.EntityFrameworkCore.SqlServer
PM> Install-Package Microsoft.EntityFrameworkCore.Tools
PM> Install-Package Microsoft.Extensions.Caching.StackExchangeRedis

Step 4: Add connection string for SQL server and Redis

  • Modify appsettings.json as follows

appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "AccountingDBConnection": "Server=localhost;Database=AccountingDB;User Id=sa;Password=yourdbserverpassword;"
  },
  //Redis Server
  "Redis": {
    "Server": "localhost:6379"
  }
}

Step 5: Create model TransactionDetails class in Models folder

TransactionDetails.cs


namespace Accounting.API.Models
{
    public class TransactionDetails
    {
        public Int64 Id { get; set; }

        // Master transaction Id
        public Int64 TransactionId { get; set; }
        
        // Id against GL Code
        public int AccountId { get; set; } 

        public decimal DrAmount { get; set; }
        public decimal CrAmount { get; set; }

        // Cash = 1, Cheque = 2
        public int TransactionType { get; set; }

        // Any note
        public string Description { get; set; }

    }
}

Step 6: Create DbContext class in Db folder as follows.

AccountingContext.cs

using Accounting.API.Models;
using Microsoft.EntityFrameworkCore;

namespace Accounting.API.Db
{
    public class AccountingContext : DbContext
    {
        public AccountingContext(DbContextOptions<AccountingContext> options) : base(options)
        {

        }

        public DbSet<TransactionDetails> TransactionDetails { get; set; }
    }
}

Step 7: Register Redis Caching server and SQL server in Program.cs file as follows.

Program.cs


using Accounting.API.Db;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

// Configuration for SQL Server connection
builder.Services.AddDbContext<AccountingContext>(options =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("AccountingDBConnection"));
});


// Register Redis caching server
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration["Redis:Server"];
});

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

Step 8: Add Migration

  • Got to package manager console and run the following command
PM> Add-Migration init-mig
PM> Update-Database -Verbose

Step 9: Create Hashgenerator class in Utility folder to create hash.

HashGenerator.cs

using System.Security.Cryptography;
using System.Text;

namespace Accounting.API.Utility
{
    public class HashGenerator
    {

        public static string GetHash(string plainText)
        {
            string hashText = string.Empty;
            hashText = ComputeSha256Hash(plainText);
            return hashText;
        }

        static string ComputeSha256Hash(string rawData)
        {
            // Create a SHA256   
            using (SHA256 sha256Hash = SHA256.Create())
            {
                // ComputeHash - returns byte array  
                byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(rawData));

                // Convert byte array to a string   
                StringBuilder builder = new StringBuilder();
                for (int i = 0; i < bytes.Length; i++)
                {
                    builder.Append(bytes[i].ToString("x2"));
                }
                return builder.ToString();
            }
        }
    }
}

Step 10: Create TransactionDetailsController controller class in Controllers folder as follows.

TransactionDetailsController.cs


using Accounting.API.Db;
using Accounting.API.Models;
using Accounting.API.Utility;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Newtonsoft.Json;
using System.Text;

namespace Accounting.API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TransactionDetailsController : ControllerBase
    {
        private readonly AccountingContext _context;
        private readonly IDistributedCache _distributedCache;

        public TransactionDetailsController(AccountingContext context, IDistributedCache distributedCache)
        {
            _context = context;
            _distributedCache = distributedCache;

        }

        // GET: api/TransactionDetails
        [HttpGet]
        public async Task<ActionResult<IEnumerable<TransactionDetails>>> GetTransactionDetails()
        {
            if (_context.TransactionDetails == null)
            {
                return NotFound();
            }
            return await _context.TransactionDetails.ToListAsync();
        }

        // GET: api/TransactionDetails/5
        [HttpGet("{id}")]
        public async Task<ActionResult<TransactionDetails>> GetTransactionDetails(long id)
        {
            if (_context.TransactionDetails == null)
            {
                return NotFound();
            }
            var transactionDetails = await _context.TransactionDetails.FindAsync(id);

            if (transactionDetails == null)
            {
                return NotFound();
            }

            return transactionDetails;
        }

        // PUT: api/TransactionDetails/5
        // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
        [HttpPut("{id}")]
        public async Task<IActionResult> PutTransactionDetails(long id, TransactionDetails transactionDetails)
        {
            if (id != transactionDetails.Id)
            {
                return BadRequest();
            }

            _context.Entry(transactionDetails).State = EntityState.Modified;

            try
            {
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!TransactionDetailsExists(id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }

            return NoContent();
        }

        // POST: api/TransactionDetails
        // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
        [HttpPost]
        public async Task<ActionResult<TransactionDetails>> PostTransactionDetails(TransactionDetails transactionDetails)
        {
            // Create a hash key using transaction id, dr and cr amount
            string idempotencyKey = HashGenerator.GetHash(transactionDetails.TransactionId.ToString() 
                + transactionDetails.DrAmount.ToString() + transactionDetails.CrAmount.ToString());


            // check hash key is exists in the redis cache
            var isCached = await _distributedCache.GetAsync(idempotencyKey).ConfigureAwait(false);

            if(isCached is not null)
            {
                // if same value is already exists in the cache then return existing value. 
                var decodedResult = JsonConvert.DeserializeObject<TransactionDetails>(Encoding.UTF8.GetString(isCached));
                //var employeeDecodedResult = _mapper.Map<EmployeeResponseDTO>(decodedResult);
                return decodedResult;
            }

            // if input object is null return with a problem
            if (_context.TransactionDetails == null)
            {
                return Problem("Entity set 'AccountingContext.TransactionDetails'  is null.");
            }


            // Save into database
            _context.TransactionDetails.Add(transactionDetails);
            await _context.SaveChangesAsync();

            // Set value into cache after save
            // value will be removed after 10 mins
            // It will be removed after 2 mins, if it is not requested within 2 mins

            var options = new DistributedCacheEntryOptions().SetAbsoluteExpiration(DateTime.Now.AddMinutes(10)).SetSlidingExpiration(TimeSpan.FromMinutes(2));
            await _distributedCache.SetAsync(idempotencyKey,
                Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(transactionDetails)), options);


            return CreatedAtAction("GetTransactionDetails", new { id = transactionDetails.Id }, transactionDetails);
        }

        // DELETE: api/TransactionDetails/5
        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteTransactionDetails(long id)
        {
            if (_context.TransactionDetails == null)
            {
                return NotFound();
            }
            var transactionDetails = await _context.TransactionDetails.FindAsync(id);
            if (transactionDetails == null)
            {
                return NotFound();
            }

            _context.TransactionDetails.Remove(transactionDetails);
            await _context.SaveChangesAsync();

            return NoContent();
        }

        private bool TransactionDetailsExists(long id)
        {
            return (_context.TransactionDetails?.Any(e => e.Id == id)).GetValueOrDefault();
        }
    }
}

Step 10: Now run application and save data using swagger UI.

  • Try to save same data twice or more
  • Check database, you will see data will save one once

Now look at the PostTransactionDetails class. Here I have created idempotencyKey (a hash key) using transaction id, debit amount and credit amount. In this case, idempotencyKey is actually request key. When we save any data, we are storing a request key of corresponding input in redis cache. Whenever, we are going to save data we are checking hash key of existing input. If it is existing then it will not save and consider as idempotent operation.

DistributedCacheEntryOptions

  • SetAbsoluteExpiration: Here you can set the expiration time of the cached object.

  • SetSlidingExpiration: This is similar to Absolute Expiration. It expires as a cached object if it not being requested for a defined amount of time period. Note that Sliding Expiration should always be set lower than the absolute expiration

Note: In distributed caching, cache is not stored in to an individual web server’s memory. Cache data is centrally managed and the same data is available to all the app servers. The distributed caching has several advantages, as shown below. The cache is stored centrally, so all the users get the same data and data is not dependent on which web server handles its request. The cache data is not impacted if any problem happens with the web server; i.e., restart, new server is added, a server is removed.

Source code