Compare commits

..

No commits in common. "5dbde2c84721849506e4c4fbb5825f23bbfa3119" and "44a3628489ce6a34398e4caaee52423897ff3a1b" have entirely different histories.

9 changed files with 55 additions and 261 deletions

View File

@ -10,53 +10,14 @@
<Button Color="ButtonColor.Success" @onclick="AddUser">Add User</Button> <Button Color="ButtonColor.Success" @onclick="AddUser">Add User</Button>
<Button Color="ButtonColor.Primary" @onclick="Refresh">Refresh</Button> <Button Color="ButtonColor.Primary" @onclick="Refresh">Refresh</Button>
@if (Status != null) @if(Status != null)
{ {
<h2>Users with access</h2> @foreach(var user in Status.Users)
<Grid TItem="User" Data="Status.AccessUsers" Class="table table-bordered mt-3" AllowSorting> {
<GridColumns> <div>
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id"> <p>@user.Id @user.LatestResponse?.HasAccess @user.LatestResponse?.ExpiresOn</p>
@context.Id </div>
</GridColumn> }
<GridColumn TItem="User" HeaderText="Expiration" PropertyName="LatestResponse?.ExpiresOn" SortKeySelector="item => item.LatestResponse.ExpiresOn">
@context.LatestResponse?.ExpiresOn
</GridColumn>
<GridColumn TItem="User" HeaderText="Active">
<ChildContent>
<Switch Value="context.Active" ValueExpression="() => context.Active" ValueChanged="(_) => ToggleUserActive(context.Id)" />
</ChildContent>
</GridColumn>
</GridColumns>
</Grid>
<h2>Users in queue</h2>
<Grid TItem="User" Data="Status.QueuedUsers" Class="table table-bordered mt-3" AllowSorting>
<GridColumns>
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id">
@context.Id
</GridColumn>
<GridColumn TItem="User" HeaderText="Queue Postition" PropertyName="LatestResponse?.RequestsAhead">
@(context.LatestResponse?.RequestsAhead ?? 0 + 1)
</GridColumn>
<GridColumn TItem="User" HeaderText="Active">
<ChildContent>
<Switch Value="context.Active" ValueExpression="() => context.Active" ValueChanged="(_) => ToggleUserActive(context.Id)" />
</ChildContent>
</GridColumn>
</GridColumns>
</Grid>
<h2>Inactive users</h2>
<Grid TItem="User" Data="Status.InactiveUsers" Class="table table-bordered mt-3" AllowSorting>
<GridColumns>
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id">
@context.Id
</GridColumn>
<GridColumn TItem="User" HeaderText="Active">
<ChildContent>
<Switch Value="context.Active" ValueExpression="() => context.Active" ValueChanged="(_) => ToggleUserActive(context.Id)" />
</ChildContent>
</GridColumn>
</GridColumns>
</Grid>
} }
@code { @code {
@ -87,9 +48,4 @@
Manager.AddUser(); Manager.AddUser();
Status = Manager.GetStatus(); Status = Manager.GetStatus();
} }
public void ToggleUserActive(Guid userId)
{
Manager.ToggleUserActivity(userId);
}
} }

View File

@ -4,8 +4,9 @@ namespace AccessQueuePlayground.Models
{ {
public class AccessQueueStatus public class AccessQueueStatus
{ {
public List<User> AccessUsers { get; set; } = []; public List<User> Users { get; set; } = [];
public List<User> QueuedUsers { get; set; } = []; public int QueueSize { get; set; }
public List<User> InactiveUsers { get; set; } = []; public int ActiveTickets { get; set; }
public int UnexpiredTickets { get; set; }
} }
} }

View File

@ -52,29 +52,13 @@ namespace AccessQueuePlayground.Services
var newStatus = new AccessQueueStatus(); var newStatus = new AccessQueueStatus();
foreach (var user in userList) foreach (var user in userList)
{ {
AccessResponse? response = user.LatestResponse;
if (user.Active) if (user.Active)
{ {
user.LatestResponse = await _accessService.RequestAccess(user.Id); response = await _accessService.RequestAccess(user.Id);
if (user.LatestResponse?.HasAccess ?? false) user.LatestResponse = response;
{
newStatus.AccessUsers.Add(user);
}
else
{
newStatus.QueuedUsers.Add(user);
}
}
else
{
if(user.LatestResponse?.ExpiresOn != null && user.LatestResponse.ExpiresOn > DateTime.UtcNow)
{
newStatus.AccessUsers.Add(user);
}
else
{
newStatus.InactiveUsers.Add(user);
}
} }
newStatus.Users.Add(user);
} }
_status = newStatus; _status = newStatus;
NotifyStatusUpdated(); NotifyStatusUpdated();

View File

@ -6,9 +6,9 @@
} }
}, },
"AccessQueue": { "AccessQueue": {
"CapacityLimit": 3, // Maximum number of active users "CapacityLimit": 10, // Maximum number of active users
"ActivitySeconds": 2, // Time since last access before a user is considered inactive "ActivitySeconds": 5, // Time since last access before a user is considered inactive
"ExpirationSeconds": 10, // 12 hours - Time before a user access is revoked "ExpirationSeconds": 30, // 12 hours - Time before a user access is revoked
"RollingExpiration": true // Whether to extend expiration time on access "RollingExpiration": true // Whether to extend expiration time on access
}, },
"AllowedHosts": "*" "AllowedHosts": "*"

View File

@ -12,7 +12,7 @@ namespace AccessQueueService.Data
public int GetActiveTicketsCount(DateTime activeCutoff) => _accessTickets public int GetActiveTicketsCount(DateTime activeCutoff) => _accessTickets
.Count(t => t.Value.ExpiresOn > DateTime.UtcNow && t.Value.LastActive >activeCutoff); .Count(t => t.Value.ExpiresOn > DateTime.UtcNow && t.Value.LastActive >activeCutoff);
public int GetQueueCount() => _accessQueue.Count; public int GetQueueCount() => _accessQueue.Count;
public int GetRequestsAhead(Guid userId) public int IndexOfTicket(Guid userId)
{ {
var index = 0; var index = 0;
foreach (var ticket in _accessQueue) foreach (var ticket in _accessQueue)

View File

@ -10,7 +10,7 @@ namespace AccessQueueService.Data
public int GetQueueCount(); public int GetQueueCount();
public AccessTicket? GetTicket(Guid userId); public AccessTicket? GetTicket(Guid userId);
public void UpsertTicket(AccessTicket ticket); public void UpsertTicket(AccessTicket ticket);
public int GetRequestsAhead(Guid userId); public int IndexOfTicket(Guid userId);
public void Enqueue(AccessTicket ticket); public void Enqueue(AccessTicket ticket);
public int DeleteExpiredTickets(); public int DeleteExpiredTickets();
public bool RemoveUser(Guid userId); public bool RemoveUser(Guid userId);

View File

@ -1,102 +0,0 @@
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

@ -68,8 +68,9 @@ namespace AccessQueueService.Services
} }
else else
{ {
var requestsAhead = _accessQueueRepo.GetRequestsAhead(userId); var indexOfTicket = _accessQueueRepo.IndexOfTicket(userId);
if (requestsAhead == -1) var requestsAhead = _accessQueueRepo.GetQueueCount() - indexOfTicket - 1;
if (indexOfTicket == -1)
{ {
_accessQueueRepo.Enqueue(new AccessTicket _accessQueueRepo.Enqueue(new AccessTicket
{ {
@ -81,7 +82,7 @@ namespace AccessQueueService.Services
return new AccessResponse return new AccessResponse
{ {
ExpiresOn = null, ExpiresOn = null,
RequestsAhead = _accessQueueRepo.GetQueueCount() - 1 RequestsAhead = requestsAhead
}; };
} }
} }

View File

@ -18,14 +18,9 @@ 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 AccessService _accessService; private readonly AccessService _accessService;
public static IEnumerable<object[]> RepoImplementations()
{
yield return new object[] { new DictionaryAccessQueueRepo() };
yield return new object[] { new TakeANumberAccessQueueRepo() };
}
private void CreateService(IAccessQueueRepo repo) public AccessServiceTests()
{ {
var inMemorySettings = new Dictionary<string, string?> var inMemorySettings = new Dictionary<string, string?>
{ {
@ -38,16 +33,14 @@ 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);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RequestAccess_ShouldGrantAccess_WhenCapacityIsAvailable()
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);
@ -60,11 +53,9 @@ namespace AccessQueueServiceTests
Assert.Equal(0, _accessService.QueueCount); Assert.Equal(0, _accessService.QueueCount);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RequestAccess_ShouldReturnAccessResponse_WhenUserAlreadyHasTicket()
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);
@ -78,11 +69,9 @@ namespace AccessQueueServiceTests
Assert.Equal(0, _accessService.QueueCount); Assert.Equal(0, _accessService.QueueCount);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RequestAccess_ShouldQueueUser_WhenCapacityIsFull()
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());
@ -100,11 +89,9 @@ namespace AccessQueueServiceTests
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RevokeAccess_ShouldReturnTrue_WhenUserHasAccess()
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);
@ -113,11 +100,9 @@ namespace AccessQueueServiceTests
Assert.True(result); Assert.True(result);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RevokeAccess_ShouldReturnFalse_WhenUserDoesNotHaveAccess()
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);
@ -125,11 +110,9 @@ namespace AccessQueueServiceTests
Assert.False(result); Assert.False(result);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RequestAccess_ShouldQueueUser_AfterAccessRevoked()
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);
@ -148,11 +131,9 @@ namespace AccessQueueServiceTests
Assert.False(responseAfterRevoke.HasAccess); Assert.False(responseAfterRevoke.HasAccess);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RequestAccess_ShouldNotQueueUser_WhenMultipleRequestsForOtherUsersMade()
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());
@ -162,11 +143,9 @@ namespace AccessQueueServiceTests
Assert.False(response.HasAccess); Assert.False(response.HasAccess);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RequestAccess_ShouldUpdateExpirationTime_WhenRollingExpirationTrue()
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);
@ -174,11 +153,9 @@ namespace AccessQueueServiceTests
Assert.True(updatedResponse.ExpiresOn > initialResponse.ExpiresOn); Assert.True(updatedResponse.ExpiresOn > initialResponse.ExpiresOn);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RequestAccess_ShouldGrantAccess_WhenUsersWithAccessInactive()
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());
@ -191,11 +168,9 @@ namespace AccessQueueServiceTests
Assert.True(response.HasAccess); Assert.True(response.HasAccess);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RequestAccess_ShouldRevokeAccess_WhenExpired()
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);
@ -208,11 +183,9 @@ namespace AccessQueueServiceTests
Assert.False(response.HasAccess); Assert.False(response.HasAccess);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RequestAccess_ShouldRetailAccess_WhenNotExpired()
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);
@ -226,11 +199,9 @@ namespace AccessQueueServiceTests
Assert.True(response.HasAccess); Assert.True(response.HasAccess);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RequestAccess_ShouldProcessBulkRequests()
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++)
@ -242,11 +213,9 @@ namespace AccessQueueServiceTests
Assert.True(response.HasAccess); Assert.True(response.HasAccess);
} }
[Theory] [Fact]
[MemberData(nameof(RepoImplementations))] public async Task RequestAccess_ShouldReportLessInQueue_AsTicketsInactivate()
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++)
{ {
@ -265,7 +234,7 @@ namespace AccessQueueServiceTests
await _accessService.RequestAccess(users[0]); await _accessService.RequestAccess(users[0]);
await _accessService.RequestAccess(users[1]); await _accessService.RequestAccess(users[1]);
var response = await _accessService.RequestAccess(users[2]); var response = await _accessService.RequestAccess(users[2]);
Assert.Equal(1, response.RequestsAhead); Assert.Equal(1, response.RequestsAhead);
await Task.Delay(ACT_MILLIS / CAP_LIMIT); await Task.Delay(ACT_MILLIS / CAP_LIMIT);
@ -276,21 +245,6 @@ namespace AccessQueueServiceTests
Assert.Equal(0, response.RequestsAhead); Assert.Equal(0, response.RequestsAhead);
} }
[Theory]
[MemberData(nameof(RepoImplementations))]
public async Task RequestAccess_ShouldShowCorrectRequestsAhead_WhenAccessRerequested(IAccessQueueRepo repo)
{
CreateService(repo);
for (int i = 0; i < CAP_LIMIT + 3; i++)
{
await _accessService.RequestAccess(Guid.NewGuid());
}
var id = Guid.NewGuid();
var response = await _accessService.RequestAccess(id);
Assert.Equal(3, response.RequestsAhead);
response = await _accessService.RequestAccess(id);
Assert.Equal(3, response?.RequestsAhead);
}
} }
} }
} }