Abstract queue and dictionary implementation to support alternative implementations

This commit is contained in:
henry 2025-05-09 19:41:07 -04:00
parent 6470ce21f3
commit c04a603d56
5 changed files with 149 additions and 78 deletions

View File

@ -0,0 +1,99 @@
using AccessQueueService.Models;
using Microsoft.Extensions.Configuration;
namespace AccessQueueService.Data
{
public class DictionaryAccessQueueRepo : IAccessQueueRepo
{
private readonly Dictionary<Guid, AccessTicket> _accessTickets = new();
private readonly Queue<AccessTicket> _accessQueue = new();
public int GetUnexpiredTicketsCount() => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow);
public int GetActiveTicketsCount(DateTime activeCutoff) => _accessTickets
.Count(t => t.Value.ExpiresOn > DateTime.UtcNow && t.Value.LastActive >activeCutoff);
public int GetQueueCount() => _accessQueue.Count;
public int IndexOfTicket(Guid userId)
{
var index = 0;
foreach (var ticket in _accessQueue)
{
if (ticket.UserId == userId)
{
return index;
}
index++;
}
return -1;
}
public void Enqueue(AccessTicket ticket)
{
_accessQueue.Enqueue(ticket);
}
public int DeleteExpiredTickets()
{
var cutoff = DateTime.UtcNow;
var expiredTickets = _accessTickets.Where(t => t.Value.ExpiresOn < cutoff);
int count = 0;
foreach (var ticket in expiredTickets)
{
count++;
_accessTickets.Remove(ticket.Key);
}
return count;
}
public void RemoveUser(Guid userId)
{
_accessTickets.Remove(userId);
}
public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit)
{
var now = DateTime.UtcNow;
var activeCutoff = now.AddSeconds(-activeSeconds);
var numberOfActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > now && t.Value.LastActive > activeCutoff);
var openSpots = capacityLimit - numberOfActiveUsers;
int filledSpots = 0;
while (filledSpots < openSpots)
{
if (_accessQueue.TryDequeue(out var nextUser))
{
if (nextUser.LastActive < activeCutoff)
{
// User is inactive, throw away their ticket
continue;
}
_accessTickets[nextUser.UserId] = new AccessTicket
{
UserId = nextUser.UserId,
ExpiresOn = now.AddSeconds(expirationSeconds),
LastActive = now
};
filledSpots++;
}
else
{
break;
}
}
return filledSpots == openSpots;
}
public AccessTicket? GetTicket(Guid userId)
{
return _accessTickets.TryGetValue(userId, out var ticket) ? ticket : null;
}
public void UpsertTicket(AccessTicket ticket)
{
_accessTickets[ticket.UserId] = ticket;
}
bool IAccessQueueRepo.RemoveUser(Guid userId)
{
return _accessTickets.Remove(userId);
}
}
}

View File

@ -0,0 +1,22 @@
using AccessQueueService.Models;
using Microsoft.Extensions.Configuration;
namespace AccessQueueService.Data
{
public interface IAccessQueueRepo
{
public int GetUnexpiredTicketsCount();
public int GetActiveTicketsCount(DateTime activeCutoff);
public int GetQueueCount();
public AccessTicket? GetTicket(Guid userId);
public void UpsertTicket(AccessTicket ticket);
public int IndexOfTicket(Guid userId);
public void Enqueue(AccessTicket ticket);
public int DeleteExpiredTickets();
public bool RemoveUser(Guid userId);
public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit);
}
}

View File

@ -1,3 +1,4 @@
using AccessQueueService.Data;
using AccessQueueService.Services; using AccessQueueService.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -9,6 +10,7 @@ builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddSingleton<IAccessService, AccessService>(); builder.Services.AddSingleton<IAccessService, AccessService>();
builder.Services.AddSingleton<IAccessQueueRepo, DictionaryAccessQueueRepo>();
var app = builder.Build(); var app = builder.Build();

View File

@ -1,35 +1,39 @@
using AccessQueueService.Models; using AccessQueueService.Data;
using AccessQueueService.Models;
namespace AccessQueueService.Services namespace AccessQueueService.Services
{ {
public class AccessService : IAccessService public class AccessService : IAccessService
{ {
private readonly Dictionary<Guid, AccessTicket> _accessTickets = new();
private readonly Queue<AccessTicket> _accessQueue = new();
private readonly SemaphoreSlim _queueLock = new(1, 1);
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IAccessQueueRepo _accessQueueRepo;
//private readonly Dictionary<Guid, AccessTicket> _accessTickets = new();
//private readonly Queue<AccessTicket> _accessQueue = new();
private readonly SemaphoreSlim _queueLock = new(1, 1);
private readonly int EXP_SECONDS; private readonly int EXP_SECONDS;
private readonly int ACT_SECONDS; private readonly int ACT_SECONDS;
private readonly int CAPACITY_LIMIT; private readonly int CAPACITY_LIMIT;
private readonly bool ROLLING_EXPIRATION; private readonly bool ROLLING_EXPIRATION;
public AccessService(IConfiguration configuration) public AccessService(IConfiguration configuration, IAccessQueueRepo accessQueueRepo)
{ {
_configuration = configuration; _configuration = configuration;
_accessQueueRepo = accessQueueRepo;
EXP_SECONDS = _configuration.GetValue<int>("AccessQueue:ExpirationSeconds"); EXP_SECONDS = _configuration.GetValue<int>("AccessQueue:ExpirationSeconds");
ACT_SECONDS = _configuration.GetValue<int>("AccessQueue:ActivitySeconds"); ACT_SECONDS = _configuration.GetValue<int>("AccessQueue:ActivitySeconds");
CAPACITY_LIMIT = _configuration.GetValue<int>("AccessQueue:CapacityLimit"); CAPACITY_LIMIT = _configuration.GetValue<int>("AccessQueue:CapacityLimit");
ROLLING_EXPIRATION = _configuration.GetValue<bool>("AccessQueue:RollingExpiration"); ROLLING_EXPIRATION = _configuration.GetValue<bool>("AccessQueue:RollingExpiration");
} }
public int UnexpiredTicketsCount => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow); public int UnexpiredTicketsCount => _accessQueueRepo.GetUnexpiredTicketsCount();
public int ActiveTicketsCount => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow && t.Value.LastActive > DateTime.UtcNow.AddSeconds(-_configuration.GetValue<int>("AccessQueue:ActivitySeconds"))); public int ActiveTicketsCount => _accessQueueRepo.GetActiveTicketsCount(DateTime.UtcNow.AddSeconds(-_configuration.GetValue<int>("AccessQueue:ActivitySeconds")));
public int QueueCount => _accessQueue.Count; public int QueueCount => _accessQueueRepo.GetQueueCount();
public async Task<AccessResponse> RequestAccess(Guid userId) public async Task<AccessResponse> RequestAccess(Guid userId)
{ {
await _queueLock.WaitAsync(); await _queueLock.WaitAsync();
try try
{ {
var hasCapacity = !DidDequeueUntilFull(); var hasCapacity = !_accessQueueRepo.DidDequeueUntilFull(ACT_SECONDS, EXP_SECONDS, CAPACITY_LIMIT);
var existingTicket = _accessTickets.GetValueOrDefault(userId); var existingTicket = _accessQueueRepo.GetTicket(userId);
if (existingTicket != null && existingTicket.ExpiresOn > DateTime.UtcNow) if (existingTicket != null && existingTicket.ExpiresOn > DateTime.UtcNow)
{ {
var expiresOn = existingTicket.ExpiresOn; var expiresOn = existingTicket.ExpiresOn;
@ -37,12 +41,12 @@ namespace AccessQueueService.Services
{ {
expiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS); expiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS);
} }
_accessTickets[userId] = new AccessTicket _accessQueueRepo.UpsertTicket(new AccessTicket
{ {
UserId = userId, UserId = userId,
ExpiresOn = expiresOn, ExpiresOn = expiresOn,
LastActive = DateTime.UtcNow LastActive = DateTime.UtcNow
}; });
return new AccessResponse return new AccessResponse
{ {
ExpiresOn = expiresOn ExpiresOn = expiresOn
@ -56,20 +60,19 @@ namespace AccessQueueService.Services
ExpiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS), ExpiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS),
LastActive = DateTime.UtcNow LastActive = DateTime.UtcNow
}; };
_accessTickets[userId] = accessTicket; _accessQueueRepo.UpsertTicket(accessTicket);
return new AccessResponse return new AccessResponse
{ {
ExpiresOn = _accessTickets[userId].ExpiresOn, ExpiresOn = accessTicket.ExpiresOn,
RequestsAhead = _accessQueue.Count
}; };
} }
else else
{ {
var indexOfTicket = IndexOfTicket(userId); var indexOfTicket = _accessQueueRepo.IndexOfTicket(userId);
var requestsAhead = _accessQueue.Count - indexOfTicket - 1; var requestsAhead = _accessQueueRepo.GetQueueCount() - indexOfTicket - 1;
if (indexOfTicket == -1) if (indexOfTicket == -1)
{ {
_accessQueue.Enqueue(new AccessTicket _accessQueueRepo.Enqueue(new AccessTicket
{ {
UserId = userId, UserId = userId,
LastActive = DateTime.UtcNow, LastActive = DateTime.UtcNow,
@ -94,7 +97,7 @@ namespace AccessQueueService.Services
await _queueLock.WaitAsync(); await _queueLock.WaitAsync();
try try
{ {
return _accessTickets.Remove(userId); return _accessQueueRepo.RemoveUser(userId);
} }
finally finally
{ {
@ -102,63 +105,6 @@ namespace AccessQueueService.Services
} }
} }
public int DeleteExpiredTickets() public int DeleteExpiredTickets() => _accessQueueRepo.DeleteExpiredTickets();
{
var now = DateTime.UtcNow;
var expiredTickets = _accessTickets.Where(t => t.Value.ExpiresOn < now);
int count = 0;
foreach (var ticket in expiredTickets)
{
count++;
_accessTickets.Remove(ticket.Key);
}
return count;
}
private bool DidDequeueUntilFull()
{
var now = DateTime.UtcNow;
var activeCutoff = now.AddSeconds(-ACT_SECONDS);
var numberOfActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > now && t.Value.LastActive > activeCutoff);
var openSpots = CAPACITY_LIMIT - numberOfActiveUsers;
int filledSpots = 0;
while (filledSpots < openSpots)
{
if (_accessQueue.TryDequeue(out var nextUser))
{
if (nextUser.LastActive < activeCutoff)
{
// User is inactive, throw away their ticket
continue;
}
_accessTickets[nextUser.UserId] = new AccessTicket
{
UserId = nextUser.UserId,
ExpiresOn = now.AddSeconds(EXP_SECONDS),
LastActive = now
};
filledSpots++;
}
else
{
break;
}
}
return filledSpots == openSpots;
}
private int IndexOfTicket(Guid userId)
{
var index = 0;
foreach (var ticket in _accessQueue)
{
if (ticket.UserId == userId)
{
return index;
}
index++;
}
return -1;
}
} }
} }

View File

@ -1,5 +1,6 @@
namespace AccessQueueServiceTests namespace AccessQueueServiceTests
{ {
using global::AccessQueueService.Data;
using global::AccessQueueService.Services; using global::AccessQueueService.Services;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using System; using System;
@ -32,8 +33,9 @@ namespace AccessQueueServiceTests
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(inMemorySettings) .AddInMemoryCollection(inMemorySettings)
.Build(); .Build();
var accessQueueRepo = new DictionaryAccessQueueRepo();
_accessService = new AccessService(configuration); _accessService = new AccessService(configuration, accessQueueRepo);
} }
[Fact] [Fact]