DEV Community

Cover image for Part 2 - High Volume Ticket Booking System using C#, EF Core, Redis Cache and SQL Server
Olabamiji Oyetubo
Olabamiji Oyetubo

Posted on

Part 2 - High Volume Ticket Booking System using C#, EF Core, Redis Cache and SQL Server

In Part 1, we built the BookingService and successfully booked a request. However, this was done as a one-off API call, which isn’t the most efficient approach.

In this article, we'll add a Background worker that queues Booking Requests and executes them in the background. We'll also introduce a Redis cache to save frequently assessed seats in a Booking request, which will reduce the load and effort on the database and ultimately improve the performance of the system.

Let's begin.

In TicketBooking.Application, run this command to install the package as we'll be needing it;

Install-Package Microsoft.Extensions.Hosting.Abstractions
Enter fullscreen mode Exit fullscreen mode

Next, add a folder called BackgroundServices and then add these 3 classes BackgroundBookingWorker, IBackgroundBookingQueue, BackgroundBookingQueue with these code snippets

 public interface IBackgroundBookingQueue
 {
     void QueueBooking(AddBookingDto request);
     IAsyncEnumerable<AddBookingDto> DequeueAsync(CancellationToken cancellationToken);
 }
Enter fullscreen mode Exit fullscreen mode
public class BackgroundBookingQueue : IBackgroundBookingQueue
{
    private readonly Channel<AddBookingDto> _queue;

    public BackgroundBookingQueue()
    {
        _queue = Channel.CreateBounded<AddBookingDto>(100);
    }

    public void QueueBooking(AddBookingDto request)
    {
        if (!_queue.Writer.TryWrite(request))
        {
            throw new InvalidOperationException("Booking queue is full.");
        }
    }

    public async IAsyncEnumerable<AddBookingDto> DequeueAsync(CancellationToken cancellationToken)
    {
        while (await _queue.Reader.WaitToReadAsync(cancellationToken))
        {
            while (_queue.Reader.TryRead(out var item))
            {
                yield return item;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We created an IBackgroundBookingQueue class, with 2 methods, QueueBooking and DequeueAsync . The former adds a Booking request to the background queue for processing, while the latter asynchronously retrieves the Booking Requests from the queue and processes them.

public class BackgroundBookingWorker : BackgroundService
{
    private readonly IBackgroundBookingQueue _queue;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<BackgroundBookingWorker> _logger;

    public BackgroundBookingWorker(
        IBackgroundBookingQueue queue,
        IServiceScopeFactory scopeFactory,
        ILogger<BackgroundBookingWorker> logger)
    {
        _queue = queue;
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("BackgroundBookingWorker started.");

        await foreach (var bookingRequest in _queue.DequeueAsync(stoppingToken))
        {
            using var scope = _scopeFactory.CreateScope();
            var bookingService = scope.ServiceProvider.GetRequiredService<IBookingService>();

            try
            {
                await bookingService.BookSeatsAsync(bookingRequest);
                _logger.LogInformation("Processed booking for screening {ScreeningId}", bookingRequest.ScreeningId);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to process booking for screening {ScreeningId}", bookingRequest.ScreeningId);
            }
        }

        _logger.LogInformation("BackgroundBookingWorker stopped.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, the BackgroundBookingWorker implements the out of the box BackgroundService class, and then listens constantly for new Booking Requests from IBackgroundBookingQueue and processes them via the ExecuteAsync method.

Next, in TicketBooking.API, navigate to the BookingController in the Controllers folder and add this new method;

[HttpPost]
[Route("queue-booking")]
public IActionResult QueueBooking([FromBody] AddBookingDto request)
{
    _queue.QueueBooking(request);
    return Ok("Booking has been queued successfully");
}
Enter fullscreen mode Exit fullscreen mode

Don't forget to add the new queue property and inject it via the constructor

   private readonly IBookingService _bookingService;
   private readonly IBackgroundBookingQueue _queue;

   public BookingController(IBookingService bookingService, IBackgroundBookingQueue queue)
   {
       _bookingService = bookingService;
       _queue = queue;
   }

Enter fullscreen mode Exit fullscreen mode

And register the new DI via the Program.cs

builder.Services.AddSingleton<IBackgroundBookingQueue, BackgroundBookingQueue>();
builder.Services.AddHostedService<BackgroundBookingWorker>();
Enter fullscreen mode Exit fullscreen mode

When you run the project and execute the QueueBooking endpoint with this payload

{
  "nameOfPerson": "Holland Thompson",
  "screeningId": 3,
  "seatNumbers": [
    "A1","A2"
  ]
}
Enter fullscreen mode Exit fullscreen mode

We get a response the the booking has been queued;

Queued Booking

If you navigate to the Logs folder in the project, where we have logged out background process, we see the process Logged and the Booking process has started.

Background Process started

Great, now the last thing we want to do is add a Redis Cache to make sure we don't always have to hit the Database for each request, especially Movies or Seats that are frequently assessed.

In TicketBooking.API add the below package;

Install-Package StackExchange.Redis
Enter fullscreen mode Exit fullscreen mode

Next, in TicketBooking.Application, Navigate to Services --> BookingService and edit the class this way

public class BookingService : IBookingService
{
    private readonly ILogger<BookingService> _logger;
    private readonly ApplicationDbContext _context;
    private readonly IDistributedCache _cache;

    public BookingService(ApplicationDbContext context, ILogger<BookingService> logger, IDistributedCache cache)
    {
        _context = context;
        _logger = logger;
        _cache = cache;
    }

    public async Task<bool> BookSeatsAsync(AddBookingDto bookingDto)
    {
        _logger.LogInformation("Booking started for screening {ScreeningId} with seats: {Seats}",
        bookingDto.ScreeningId, string.Join(", ", bookingDto.SeatNumbers));

        Screening? screening;
        var cacheKey = $"screening:{bookingDto.ScreeningId}";
        try
        {
            var cachedScreening = await _cache.GetStringAsync(cacheKey);
            if (!string.IsNullOrEmpty(cachedScreening))
            {
                screening = JsonSerializer.Deserialize<Screening>(cachedScreening, new JsonSerializerOptions
                {
                    ReferenceHandler = ReferenceHandler.IgnoreCycles,
                    PropertyNameCaseInsensitive = true
                })!;
                _logger.LogInformation("Screening loaded from Redis cache.");
            }
            else
            {
                screening = await _context.Screenings
                    .Include(s => s.Seats)
                    .FirstOrDefaultAsync(s => s.Id == bookingDto.ScreeningId);

                if (screening == null)
                {
                    _logger.LogWarning("Screening not found for ID: {ScreeningId}", bookingDto.ScreeningId);
                    return false;
                }

                var serialized = JsonSerializer.Serialize(screening, new JsonSerializerOptions
                {
                    ReferenceHandler = ReferenceHandler.IgnoreCycles,
                    WriteIndented = false
                });

                await _cache.SetStringAsync(cacheKey, serialized, new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
                });

                _logger.LogInformation("Screening cached to Redis.");
            }

            var targetSeats = screening.Seats
               .Where(seat => !string.IsNullOrWhiteSpace(seat.SeatNumber)
               && bookingDto.SeatNumbers.Contains(seat.SeatNumber))
               .ToList();

            if(targetSeats.Any(x => x.IsBooked))
            {
                //Seats are taken
                _logger.LogWarning("No available seats found for booking for screening {ScreeningId} with seats: {Seats}",
                    bookingDto.ScreeningId, string.Join(", ", bookingDto.SeatNumbers));
                IEnumerable<string> seatNumber = targetSeats.Where(x => x.IsBooked).Select(x => x.SeatNumber);
                throw new SeatAlreadyBookedException(seatNumber);
            }

            if(targetSeats.Count == 0)
            {
                //Seats does not exist
                _logger.LogWarning("Seats not found for booking for screening {ScreeningId} with seats: {Seats}",
                    bookingDto.ScreeningId, string.Join(", ", bookingDto.SeatNumbers));
                throw new AppException("Seats not found for booking", 404);
            }

            foreach (var seat in targetSeats)
            {
                seat.IsBooked = true;
            }

            var booking = new Core.Entities.Booking
            {
                NameOfPerson = bookingDto.NameOfPerson,
                ScreeningId = bookingDto.ScreeningId,
                BookedSeats = targetSeats.Select(s => s.SeatNumber).ToList(),
                BookedAt = DateTime.UtcNow
            };

            _context.Bookings.Add(booking);

            await _context.SaveChangesAsync();

            await _cache.RemoveAsync(cacheKey);
            _logger.LogInformation("Cache invalidated for screening {ScreeningId}", bookingDto.ScreeningId);

            return true;
        }
        catch (AppException ex)
        {
            _logger.LogWarning(ex, "Booking failed due to: {Message}", ex.Message);
            throw;
        }
        catch (DbUpdateConcurrencyException ex)
        {
            _logger.LogError(ex, "Concurrency conflict while booking seats for screening {ScreeningId}", bookingDto.ScreeningId);
            throw new AppException("Seat booking failed due to a concurrency issue. Please try again.", 409);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unexpected error in BookSeatsAsync for screening {ScreeningId}", bookingDto.ScreeningId);
            throw;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We have introduced the new property

private readonly IDistributedCache _cache;
Enter fullscreen mode Exit fullscreen mode

And then in BookSeatsAsync we have added this block of code;

      var cacheKey = $"screening:{bookingDto.ScreeningId}";
        try
        {
            var cachedScreening = await _cache.GetStringAsync(cacheKey);
            if (!string.IsNullOrEmpty(cachedScreening))
            {
                screening = JsonSerializer.Deserialize<Screening>(cachedScreening, new JsonSerializerOptions
                {
                    ReferenceHandler = ReferenceHandler.IgnoreCycles,
                    PropertyNameCaseInsensitive = true
                })!;
                _logger.LogInformation("Screening loaded from Redis cache.");
            }

Enter fullscreen mode Exit fullscreen mode

We have generated a cacheKey property out of the ScreeningId. It then tries to get cached screening Data from Redis, If cached data is found it deserializes the found data and avoids having to make a database call.

In the Else block, we also have this new implementation;

   else
   {
       screening = await _context.Screenings
           .Include(s => s.Seats)
           .FirstOrDefaultAsync(s => s.Id == bookingDto.ScreeningId);

       if (screening == null)
       {
           _logger.LogWarning("Screening not found for ID: {ScreeningId}", bookingDto.ScreeningId);
           return false;
       }

       var serialized = JsonSerializer.Serialize(screening, new JsonSerializerOptions
       {
           ReferenceHandler = ReferenceHandler.IgnoreCycles,
           WriteIndented = false
       });

       await _cache.SetStringAsync(cacheKey, serialized, new DistributedCacheEntryOptions
       {
           AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
       });

       _logger.LogInformation("Screening cached to Redis.");
   }
Enter fullscreen mode Exit fullscreen mode

Here we are adding a new entry to the cache that is frequently accessed.

We also have this block;

await _cache.RemoveAsync(cacheKey);
Enter fullscreen mode Exit fullscreen mode

Which updates a cache entry, ensuring that outdated or irrelevant data is removed from Redis.

And that's it, we have now been able to add a cache to the application which will undoubtedly improve the perfomance of the entire system.

If you got lost somewhere along the way, the entire part 2 project branch can ben found here

Happy coding.

Top comments (0)