Add project files.

This commit is contained in:
xxhen 2025-05-08 21:34:12 -04:00
parent 33f394550d
commit 6c9da8e796
15 changed files with 675 additions and 0 deletions

31
AccessQueueService.sln Normal file
View File

@ -0,0 +1,31 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.13.35931.197
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueService", "AccessQueueService\AccessQueueService.csproj", "{6E556254-63D4-4CEB-9CAF-E912C00E0C30}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueServiceTests", "AccessQueueServiceTests\AccessQueueServiceTests.csproj", "{1DF48A19-A2B3-4B0C-B726-E65B8E023760}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{6E556254-63D4-4CEB-9CAF-E912C00E0C30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6E556254-63D4-4CEB-9CAF-E912C00E0C30}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6E556254-63D4-4CEB-9CAF-E912C00E0C30}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6E556254-63D4-4CEB-9CAF-E912C00E0C30}.Release|Any CPU.Build.0 = Release|Any CPU
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0B4CB38D-5CDA-4E77-97C8-41A555171F52}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@AccessQueueService_HostAddress = http://localhost:5199
GET {{AccessQueueService_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -0,0 +1,32 @@
using AccessQueueService.Models;
using AccessQueueService.Services;
using Microsoft.AspNetCore.Mvc;
namespace AccessQueueService.Controllers
{
[ApiController]
[Route("access")]
public class AccessController : ControllerBase
{
private readonly IAccessService _accessService;
public AccessController(IAccessService accessService)
{
_accessService = accessService;
}
[HttpGet]
[Route("{id}")]
public async Task<AccessResponse> Get(Guid id)
{
return await _accessService.RequestAccess(id);
}
[HttpDelete]
[Route("{id}")]
public async Task<bool> Delete(Guid id)
{
return await _accessService.RevokeAccess(id);
}
}
}

View File

@ -0,0 +1,15 @@
namespace AccessQueueService.Models
{
public class AccessResponse
{
public DateTime? ExpiresOn { get; set; }
public int RequestsAhead { get; set; } = 0;
public bool HasAccess
{
get
{
return ExpiresOn != null && ExpiresOn > DateTime.UtcNow;
}
}
}
}

View File

@ -0,0 +1,9 @@
namespace AccessQueueService.Models
{
public class AccessTicket
{
public Guid UserId { get; set; }
public DateTime ExpiresOn { get; set; }
public DateTime LastActive { get; set; }
}
}

View File

@ -0,0 +1,29 @@
using AccessQueueService.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSingleton<IAccessService, AccessService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:53481",
"sslPort": 44342
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5199",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7291;http://localhost:5199",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,162 @@
using AccessQueueService.Models;
namespace AccessQueueService.Services
{
public class AccessService : IAccessService
{
private readonly Dictionary<Guid, AccessTicket> _accessTickets = new();
private readonly Queue<AccessTicket> _accessQueue = new();
private static SemaphoreSlim _queueLock = new(1, 1);
private IConfiguration _configuration;
private readonly int EXP_SECONDS;
private readonly int ACT_SECONDS;
private readonly int CAPACITY_LIMIT;
private readonly bool ROLLING_EXPIRATION;
public AccessService(IConfiguration configuration)
{
_configuration = configuration;
EXP_SECONDS = _configuration.GetValue<int>("AccessQueue:ExpirationSeconds");
ACT_SECONDS = _configuration.GetValue<int>("AccessQueue:ActivitySeconds");
CAPACITY_LIMIT = _configuration.GetValue<int>("AccessQueue:CapacityLimit");
ROLLING_EXPIRATION = _configuration.GetValue<bool>("AccessQueue:RollingExpiration");
}
public int UnexpiredTicketsCount => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow);
public int ActiveTicketsCount => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow && t.Value.LastActive > DateTime.UtcNow.AddSeconds(-_configuration.GetValue<int>("AccessQueue:ActivitySeconds")));
public int QueueCount => _accessQueue.Count;
public async Task<AccessResponse> RequestAccess(Guid userId)
{
await _queueLock.WaitAsync();
try
{
var hasCapacity = !DidDequeueUntilFull();
var existingTicket = _accessTickets.GetValueOrDefault(userId);
if (existingTicket != null && existingTicket.ExpiresOn > DateTime.UtcNow)
{
var expiresOn = existingTicket.ExpiresOn;
if (ROLLING_EXPIRATION)
{
expiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS);
}
_accessTickets[userId] = new AccessTicket
{
UserId = userId,
ExpiresOn = expiresOn,
LastActive = DateTime.UtcNow
};
return new AccessResponse
{
ExpiresOn = expiresOn
};
}
if (hasCapacity)
{
var accessTicket = new AccessTicket
{
UserId = userId,
ExpiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS),
LastActive = DateTime.UtcNow
};
_accessTickets[userId] = accessTicket;
return new AccessResponse
{
ExpiresOn = _accessTickets[userId].ExpiresOn,
RequestsAhead = _accessQueue.Count
};
}
else
{
var indexOfTicket = IndexOfTicket(userId);
var requestsAhead = _accessQueue.Count - indexOfTicket - 1;
if (indexOfTicket == -1)
{
_accessQueue.Enqueue(new AccessTicket
{
UserId = userId,
LastActive = DateTime.UtcNow,
ExpiresOn = DateTime.MaxValue,
});
}
return new AccessResponse
{
ExpiresOn = null,
RequestsAhead = requestsAhead
};
}
}
finally
{
_queueLock.Release();
}
}
public async Task<bool> RevokeAccess(Guid userId)
{
await _queueLock.WaitAsync();
try
{
return _accessTickets.Remove(userId);
}
finally
{
_queueLock.Release();
}
}
public int DeleteExpiredTickets()
{
var expiredTickets = _accessTickets.Where(t => t.Value.ExpiresOn < DateTime.UtcNow);
int count = 0;
foreach (var ticket in expiredTickets)
{
count++;
_accessTickets.Remove(ticket.Key);
}
return count;
}
private bool DidDequeueUntilFull()
{
var activeCutoff = DateTime.UtcNow.AddSeconds(-ACT_SECONDS);
var numberOfActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow && 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 = DateTime.UtcNow.AddSeconds(EXP_SECONDS),
LastActive = DateTime.UtcNow
};
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

@ -0,0 +1,11 @@
using AccessQueueService.Models;
namespace AccessQueueService.Services
{
public interface IAccessService
{
public Task<AccessResponse> RequestAccess(Guid userId);
public Task<bool> RevokeAccess(Guid userId);
public int DeleteExpiredTickets();
}
}

View File

@ -0,0 +1,13 @@
namespace AccessQueueService
{
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AccessQueue": {
"CapacityLimit": 100, // Maximum number of active users
"ActivitySeconds": 900, // Time since last access before a user is considered inactive
"ExpirationSeconds": 43200, // 12 hours - Time before a user access is revoked
"RollingExpiration": true // Whether to extend expiration time on access
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AccessQueueService\AccessQueueService.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,263 @@
namespace AccessQueueServiceTests
{
using global::AccessQueueService.Services;
using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit;
namespace AccessQueueService.Tests
{
public class AccessServiceTests
{
const int EXP_SECONDS = 5;
const int EXP_MILLIS = 1000 * EXP_SECONDS;
const int ACT_SECONDS = 1;
const int ACT_MILLIS = 1000 * ACT_SECONDS;
const int CAP_LIMIT = 5;
const int BULK_COUNT = 10000;
private readonly AccessService _accessService;
public AccessServiceTests()
{
var inMemorySettings = new Dictionary<string, string?>
{
{ "AccessQueue:ExpirationSeconds", $"{EXP_SECONDS}" },
{ "AccessQueue:ActivitySeconds", $"{ACT_SECONDS}" },
{ "AccessQueue:CapacityLimit", $"{CAP_LIMIT}" },
{ "AccessQueue:RollingExpiration", "true" }
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(inMemorySettings) // Add in-memory settings
.Build();
_accessService = new AccessService(configuration);
}
[Fact]
public async Task RequestAccess_ShouldGrantAccess_WhenCapacityIsAvailable()
{
// Arrange
var userId = Guid.NewGuid();
// Act
var response = await _accessService.RequestAccess(userId);
// Assert
Assert.NotNull(response);
Assert.NotNull(response.ExpiresOn);
Assert.True(response.RequestsAhead == 0);
Assert.Equal(1, _accessService.UnexpiredTicketsCount);
Assert.Equal(1, _accessService.ActiveTicketsCount);
Assert.Equal(0, _accessService.QueueCount);
}
[Fact]
public async Task RequestAccess_ShouldReturnAccessResponse_WhenUserAlreadyHasTicket()
{
// Arrange
var userId = Guid.NewGuid();
await _accessService.RequestAccess(userId); // First request to create a ticket
// Act
var response = await _accessService.RequestAccess(userId); // Second request for the same user
// Assert
Assert.NotNull(response);
Assert.NotNull(response.ExpiresOn);
Assert.True(response.RequestsAhead == 0);
Assert.Equal(1, _accessService.UnexpiredTicketsCount);
Assert.Equal(1, _accessService.ActiveTicketsCount);
Assert.Equal(0, _accessService.QueueCount);
}
[Fact]
public async Task RequestAccess_ShouldQueueUser_WhenCapacityIsFull()
{
// Arrange
for (int i = 0; i < CAP_LIMIT * 2; i++) // Fill double capacity
{
await _accessService.RequestAccess(Guid.NewGuid());
}
var userId = Guid.NewGuid();
// Act
var response = await _accessService.RequestAccess(userId);
// Assert
Assert.NotNull(response);
Assert.Null(response.ExpiresOn);
Assert.True(response.RequestsAhead == 5);
Assert.Equal(5, _accessService.UnexpiredTicketsCount);
Assert.Equal(5, _accessService.ActiveTicketsCount);
Assert.Equal(6, _accessService.QueueCount);
}
[Fact]
public async Task RevokeAccess_ShouldReturnTrue_WhenUserHasAccess()
{
// Arrange
var userId = Guid.NewGuid();
await _accessService.RequestAccess(userId);
// Act
var result = await _accessService.RevokeAccess(userId);
// Assert
Assert.True(result);
}
[Fact]
public async Task RevokeAccess_ShouldReturnFalse_WhenUserDoesNotHaveAccess()
{
// Arrange
var userId = Guid.NewGuid();
// Act
var result = await _accessService.RevokeAccess(userId);
// Assert
Assert.False(result);
}
[Fact]
public async Task RequestAccess_ShouldQueueUser_AfterAccessRevoked()
{
var userId = Guid.NewGuid();
await _accessService.RequestAccess(userId);
for (int i = 0; i < CAP_LIMIT; i++) // Fill remaining slots
{
await _accessService.RequestAccess(Guid.NewGuid());
}
var response = await _accessService.RequestAccess(userId); // Request access before revoking
Assert.NotNull(response);
Assert.True(response.HasAccess);
await _accessService.RevokeAccess(userId); // Revoke access
var responseAfterRevoke = await _accessService.RequestAccess(userId); // Request access again
Assert.NotNull(responseAfterRevoke);
Assert.False(responseAfterRevoke.HasAccess);
}
[Fact]
public async Task RequestAccess_ShouldNotQueueUser_WhenMultipleRequestsForOtherUsersMade()
{
for (int i = 0; i < CAP_LIMIT; i++) // Fill slots without awaiting
{
_ = _accessService.RequestAccess(Guid.NewGuid());
}
var response = await _accessService.RequestAccess(Guid.NewGuid()); // Request access before revoking
Assert.NotNull(response);
Assert.False(response.HasAccess);
}
[Fact]
public async Task RequestAccess_ShouldUpdateExpirationTime_WhenRollingExpirationTrue()
{
var userId = Guid.NewGuid();
var initialResponse = await _accessService.RequestAccess(userId);
await Task.Delay(ACT_MILLIS);
var updatedResponse = await _accessService.RequestAccess(userId);
Assert.True(updatedResponse.ExpiresOn > initialResponse.ExpiresOn);
}
[Fact]
public async Task RequestAccess_ShouldGrantAccess_WhenUsersWithAccessInactive()
{
for (int i = 0; i < CAP_LIMIT; i++)
{
await _accessService.RequestAccess(Guid.NewGuid());
}
var userId = Guid.NewGuid();
var response = await _accessService.RequestAccess(userId);
Assert.False(response.HasAccess);
await Task.Delay(ACT_MILLIS);
response = await _accessService.RequestAccess(userId);
Assert.True(response.HasAccess);
}
[Fact]
public async Task RequestAccess_ShouldRevokeAccess_WhenExpired()
{
var userId = Guid.NewGuid();
var response = await _accessService.RequestAccess(userId);
Assert.True(response.HasAccess);
await Task.Delay(EXP_MILLIS);
for (int i = 0; i < CAP_LIMIT; i++)
{
await _accessService.RequestAccess(Guid.NewGuid());
}
response = await _accessService.RequestAccess(userId);
Assert.False(response.HasAccess);
}
[Fact]
public async Task RequestAccess_ShouldRetailAccess_WhenNotExpired()
{
var userId = Guid.NewGuid();
var response = await _accessService.RequestAccess(userId);
Assert.True(response.HasAccess);
await Task.Delay(ACT_MILLIS);
for (int i = 0; i < CAP_LIMIT; i++)
{
response = await _accessService.RequestAccess(Guid.NewGuid());
Assert.True(response.HasAccess);
}
response = await _accessService.RequestAccess(userId);
Assert.True(response.HasAccess);
}
[Fact]
public async Task RequestAccess_ShouldProcessBulkRequests()
{
var userId = Guid.NewGuid();
await _accessService.RequestAccess(userId);
for (int i = 0; i < BULK_COUNT; i++)
{
_ = _accessService.RequestAccess(Guid.NewGuid());
}
var response = await _accessService.RequestAccess(userId);
Assert.NotNull(response);
Assert.True(response.HasAccess);
}
[Fact]
public async Task RequestAccess_ShouldReportLessInQueue_AsTicketsInactivate()
{
var start = DateTime.UtcNow;
for (int i = 0; i < CAP_LIMIT; i++) // Fill all slots
{
var elapsed = DateTime.UtcNow - start;
Console.WriteLine($"Elapsed time: {elapsed.TotalSeconds} s: Adding {i}");
await _accessService.RequestAccess(Guid.NewGuid());
await Task.Delay(ACT_MILLIS / CAP_LIMIT);
}
var users = new[]
{
Guid.NewGuid(),
Guid.NewGuid(),
Guid.NewGuid()
};
await _accessService.RequestAccess(users[0]);
await _accessService.RequestAccess(users[1]);
var response = await _accessService.RequestAccess(users[2]);
Assert.Equal(1, response.RequestsAhead);
await Task.Delay(ACT_MILLIS / CAP_LIMIT);
await _accessService.RequestAccess(users[0]);
await _accessService.RequestAccess(users[1]);
response = await _accessService.RequestAccess(users[2]);
Assert.Equal(0, response.RequestsAhead);
}
}
}
}