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
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);
}
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;
}
}
}
}
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.");
}
}
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");
}
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;
}
And register the new DI via the Program.cs
builder.Services.AddSingleton<IBackgroundBookingQueue, BackgroundBookingQueue>();
builder.Services.AddHostedService<BackgroundBookingWorker>();
When you run the project and execute the QueueBooking
endpoint with this payload
{
"nameOfPerson": "Holland Thompson",
"screeningId": 3,
"seatNumbers": [
"A1","A2"
]
}
We get a response the the booking has been queued;
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.
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
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;
}
}
}
We have introduced the new property
private readonly IDistributedCache _cache;
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.");
}
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.");
}
Here we are adding a new entry to the cache that is frequently accessed.
We also have this block;
await _cache.RemoveAsync(cacheKey);
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)