Added take-a-number implementation

This commit is contained in:
henry 2025-05-12 22:37:48 -04:00
parent 036168359b
commit 7b0478539e
2 changed files with 168 additions and 33 deletions

View File

@ -0,0 +1,102 @@
using AccessQueueService.Models;
using Microsoft.Extensions.Configuration;
namespace AccessQueueService.Data
{
public class TakeANumberAccessQueueRepo : IAccessQueueRepo
{
private readonly Dictionary<Guid, AccessTicket> _accessTickets = [];
private readonly Dictionary<Guid, ulong> _queueNumbers = [];
private readonly Dictionary<ulong, AccessTicket> _accessQueue = [];
private ulong _nowServing = 0;
private ulong _nextUnusedTicket = 0;
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() => (int)(_nextUnusedTicket - _nowServing);
public int GetRequestsAhead(Guid userId)
{
if(_queueNumbers.TryGetValue(userId, out var queueNumber))
{
return queueNumber >= _nowServing ? (int)(queueNumber - _nowServing) : -1;
}
return -1;
}
public void Enqueue(AccessTicket ticket)
{
_queueNumbers[ticket.UserId] = _nextUnusedTicket;
_accessQueue[_nextUnusedTicket] = ticket;
_nextUnusedTicket++;
}
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 && _nowServing < _nextUnusedTicket)
{
if (_accessQueue.TryGetValue(_nowServing, out var nextUser))
{
_nowServing++;
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

@ -18,9 +18,14 @@ namespace AccessQueueServiceTests
const int ACT_MILLIS = 1000 * ACT_SECONDS; const int ACT_MILLIS = 1000 * ACT_SECONDS;
const int CAP_LIMIT = 5; const int CAP_LIMIT = 5;
const int BULK_COUNT = 50000; const int BULK_COUNT = 50000;
private readonly AccessService _accessService; private AccessService _accessService;
public static IEnumerable<object[]> RepoImplementations()
{
yield return new object[] { new DictionaryAccessQueueRepo() };
yield return new object[] { new TakeANumberAccessQueueRepo() };
}
public AccessServiceTests() private void CreateService(IAccessQueueRepo repo)
{ {
var inMemorySettings = new Dictionary<string, string?> var inMemorySettings = new Dictionary<string, string?>
{ {
@ -33,14 +38,16 @@ namespace AccessQueueServiceTests
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(inMemorySettings) .AddInMemoryCollection(inMemorySettings)
.Build(); .Build();
var accessQueueRepo = new DictionaryAccessQueueRepo();
_accessService = new AccessService(configuration, accessQueueRepo);
_accessService = new AccessService(configuration, repo);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldGrantAccess_WhenCapacityIsAvailable() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldGrantAccess_WhenCapacityIsAvailable(IAccessQueueRepo repo)
{ {
CreateService(repo);
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var response = await _accessService.RequestAccess(userId); var response = await _accessService.RequestAccess(userId);
@ -53,9 +60,11 @@ namespace AccessQueueServiceTests
Assert.Equal(0, _accessService.QueueCount); Assert.Equal(0, _accessService.QueueCount);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldReturnAccessResponse_WhenUserAlreadyHasTicket() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldReturnAccessResponse_WhenUserAlreadyHasTicket(IAccessQueueRepo repo)
{ {
CreateService(repo);
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
await _accessService.RequestAccess(userId); await _accessService.RequestAccess(userId);
@ -69,9 +78,11 @@ namespace AccessQueueServiceTests
Assert.Equal(0, _accessService.QueueCount); Assert.Equal(0, _accessService.QueueCount);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldQueueUser_WhenCapacityIsFull() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldQueueUser_WhenCapacityIsFull(IAccessQueueRepo repo)
{ {
CreateService(repo);
for (int i = 0; i < CAP_LIMIT * 2; i++) // Fill double capacity for (int i = 0; i < CAP_LIMIT * 2; i++) // Fill double capacity
{ {
await _accessService.RequestAccess(Guid.NewGuid()); await _accessService.RequestAccess(Guid.NewGuid());
@ -89,9 +100,11 @@ namespace AccessQueueServiceTests
} }
[Fact] [Theory]
public async Task RevokeAccess_ShouldReturnTrue_WhenUserHasAccess() [MemberData(nameof(RepoImplementations))]
public async Task RevokeAccess_ShouldReturnTrue_WhenUserHasAccess(IAccessQueueRepo repo)
{ {
CreateService(repo);
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
await _accessService.RequestAccess(userId); await _accessService.RequestAccess(userId);
@ -100,9 +113,11 @@ namespace AccessQueueServiceTests
Assert.True(result); Assert.True(result);
} }
[Fact] [Theory]
public async Task RevokeAccess_ShouldReturnFalse_WhenUserDoesNotHaveAccess() [MemberData(nameof(RepoImplementations))]
public async Task RevokeAccess_ShouldReturnFalse_WhenUserDoesNotHaveAccess(IAccessQueueRepo repo)
{ {
CreateService(repo);
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var result = await _accessService.RevokeAccess(userId); var result = await _accessService.RevokeAccess(userId);
@ -110,9 +125,11 @@ namespace AccessQueueServiceTests
Assert.False(result); Assert.False(result);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldQueueUser_AfterAccessRevoked() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldQueueUser_AfterAccessRevoked(IAccessQueueRepo repo)
{ {
CreateService(repo);
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
await _accessService.RequestAccess(userId); await _accessService.RequestAccess(userId);
@ -131,9 +148,11 @@ namespace AccessQueueServiceTests
Assert.False(responseAfterRevoke.HasAccess); Assert.False(responseAfterRevoke.HasAccess);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldNotQueueUser_WhenMultipleRequestsForOtherUsersMade() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldNotQueueUser_WhenMultipleRequestsForOtherUsersMade(IAccessQueueRepo repo)
{ {
CreateService(repo);
for (int i = 0; i < CAP_LIMIT; i++) // Fill slots without awaiting for (int i = 0; i < CAP_LIMIT; i++) // Fill slots without awaiting
{ {
_ = _accessService.RequestAccess(Guid.NewGuid()); _ = _accessService.RequestAccess(Guid.NewGuid());
@ -143,9 +162,11 @@ namespace AccessQueueServiceTests
Assert.False(response.HasAccess); Assert.False(response.HasAccess);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldUpdateExpirationTime_WhenRollingExpirationTrue() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldUpdateExpirationTime_WhenRollingExpirationTrue(IAccessQueueRepo repo)
{ {
CreateService(repo);
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var initialResponse = await _accessService.RequestAccess(userId); var initialResponse = await _accessService.RequestAccess(userId);
await Task.Delay(ACT_MILLIS); await Task.Delay(ACT_MILLIS);
@ -153,9 +174,11 @@ namespace AccessQueueServiceTests
Assert.True(updatedResponse.ExpiresOn > initialResponse.ExpiresOn); Assert.True(updatedResponse.ExpiresOn > initialResponse.ExpiresOn);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldGrantAccess_WhenUsersWithAccessInactive() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldGrantAccess_WhenUsersWithAccessInactive(IAccessQueueRepo repo)
{ {
CreateService(repo);
for (int i = 0; i < CAP_LIMIT; i++) for (int i = 0; i < CAP_LIMIT; i++)
{ {
await _accessService.RequestAccess(Guid.NewGuid()); await _accessService.RequestAccess(Guid.NewGuid());
@ -168,9 +191,11 @@ namespace AccessQueueServiceTests
Assert.True(response.HasAccess); Assert.True(response.HasAccess);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldRevokeAccess_WhenExpired() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldRevokeAccess_WhenExpired(IAccessQueueRepo repo)
{ {
CreateService(repo);
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var response = await _accessService.RequestAccess(userId); var response = await _accessService.RequestAccess(userId);
Assert.True(response.HasAccess); Assert.True(response.HasAccess);
@ -183,9 +208,11 @@ namespace AccessQueueServiceTests
Assert.False(response.HasAccess); Assert.False(response.HasAccess);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldRetailAccess_WhenNotExpired() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldRetailAccess_WhenNotExpired(IAccessQueueRepo repo)
{ {
CreateService(repo);
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var response = await _accessService.RequestAccess(userId); var response = await _accessService.RequestAccess(userId);
Assert.True(response.HasAccess); Assert.True(response.HasAccess);
@ -199,9 +226,11 @@ namespace AccessQueueServiceTests
Assert.True(response.HasAccess); Assert.True(response.HasAccess);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldProcessBulkRequests() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldProcessBulkRequests(IAccessQueueRepo repo)
{ {
CreateService(repo);
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
await _accessService.RequestAccess(userId); await _accessService.RequestAccess(userId);
for (int i = 0; i < BULK_COUNT; i++) for (int i = 0; i < BULK_COUNT; i++)
@ -213,9 +242,11 @@ namespace AccessQueueServiceTests
Assert.True(response.HasAccess); Assert.True(response.HasAccess);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldReportLessInQueue_AsTicketsInactivate() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldReportLessInQueue_AsTicketsInactivate(IAccessQueueRepo repo)
{ {
CreateService(repo);
var start = DateTime.UtcNow; var start = DateTime.UtcNow;
for (int i = 0; i < CAP_LIMIT; i++) for (int i = 0; i < CAP_LIMIT; i++)
{ {
@ -245,9 +276,11 @@ namespace AccessQueueServiceTests
Assert.Equal(0, response.RequestsAhead); Assert.Equal(0, response.RequestsAhead);
} }
[Fact] [Theory]
public async Task RequestAccess_ShouldShowCorrectRequestsAhead_WhenAccessRerequested() [MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldShowCorrectRequestsAhead_WhenAccessRerequested(IAccessQueueRepo repo)
{ {
CreateService(repo);
for (int i = 0; i < CAP_LIMIT + 3; i++) for (int i = 0; i < CAP_LIMIT + 3; i++)
{ {
await _accessService.RequestAccess(Guid.NewGuid()); await _accessService.RequestAccess(Guid.NewGuid());