Compare commits

..

No commits in common. "main" and "restore-state" have entirely different histories.

18 changed files with 136 additions and 303 deletions

View File

@ -1,5 +1,5 @@
@page "/config" @page "/config"
@inject AccessQueuePlayground.Services.AccessQueueManager QueueManager @inject AccessQueuePlayground.Services.IAccessQueueManager QueueManager
@using BlazorBootstrap @using BlazorBootstrap
<h3>Access Queue Configuration</h3> <h3>Access Queue Configuration</h3>
@ -31,14 +31,6 @@
<div class="text-danger">Please enter a positive integer.</div> <div class="text-danger">Please enter a positive integer.</div>
} }
</div> </div>
<div class="mb-3">
<label for="cacheMillis">Cache Milliseconds</label>
<TextInput Id="cacheMillis" @bind-Value="config.CacheMilliseconds" Type="TextInputType.Number" />
@if (!isCacheMillisValid)
{
<div class="text-danger">Please enter a positive integer or zero.</div>
}
</div>
<div class="mb-3"> <div class="mb-3">
<Switch Id="rollingExpiration" @bind-Value="config.RollingExpiration" Label="Rolling Expiration" /> <Switch Id="rollingExpiration" @bind-Value="config.RollingExpiration" Label="Rolling Expiration" />
</div> </div>
@ -54,7 +46,6 @@
private bool isCapacityLimitValid = true; private bool isCapacityLimitValid = true;
private bool isActivitySecondsValid = true; private bool isActivitySecondsValid = true;
private bool isExpirationSecondsValid = true; private bool isExpirationSecondsValid = true;
private bool isCacheMillisValid = true;
private string? successMessage; private string? successMessage;
protected override void OnInitialized() protected override void OnInitialized()
@ -65,20 +56,18 @@
ActivitySeconds = (current.ActivitySeconds ?? 0).ToString(), ActivitySeconds = (current.ActivitySeconds ?? 0).ToString(),
CapacityLimit = (current.CapacityLimit ?? 0).ToString(), CapacityLimit = (current.CapacityLimit ?? 0).ToString(),
ExpirationSeconds = (current.ExpirationSeconds ?? 0).ToString(), ExpirationSeconds = (current.ExpirationSeconds ?? 0).ToString(),
CacheMilliseconds = (current.CacheMilliseconds ?? 0).ToString(),
RollingExpiration = current.RollingExpiration ?? false RollingExpiration = current.RollingExpiration ?? false
}; };
ValidateInputs(); ValidateInputs();
} }
private bool IsFormValid => isCapacityLimitValid && isActivitySecondsValid && isExpirationSecondsValid && isCacheMillisValid; private bool IsFormValid => isCapacityLimitValid && isActivitySecondsValid && isExpirationSecondsValid;
private void ValidateInputs() private void ValidateInputs()
{ {
isCapacityLimitValid = int.TryParse(config.CapacityLimit, out var cap) && cap > 0; isCapacityLimitValid = int.TryParse(config.CapacityLimit, out var cap) && cap > 0;
isActivitySecondsValid = int.TryParse(config.ActivitySeconds, out var act) && act > 0; isActivitySecondsValid = int.TryParse(config.ActivitySeconds, out var act) && act > 0;
isExpirationSecondsValid = int.TryParse(config.ExpirationSeconds, out var exp) && exp > 0; isExpirationSecondsValid = int.TryParse(config.ExpirationSeconds, out var exp) && exp > 0;
isCacheMillisValid = int.TryParse(config.CacheMilliseconds, out var cache) && cache >= 0;
} }
private async Task HandleValidSubmit() private async Task HandleValidSubmit()
@ -92,7 +81,6 @@
ActivitySeconds = int.Parse(config.ActivitySeconds), ActivitySeconds = int.Parse(config.ActivitySeconds),
CapacityLimit = int.Parse(config.CapacityLimit), CapacityLimit = int.Parse(config.CapacityLimit),
ExpirationSeconds = int.Parse(config.ExpirationSeconds), ExpirationSeconds = int.Parse(config.ExpirationSeconds),
CacheMilliseconds = int.Parse(config.CacheMilliseconds),
RollingExpiration = config.RollingExpiration RollingExpiration = config.RollingExpiration
})); }));
successMessage = "Configuration updated successfully."; successMessage = "Configuration updated successfully.";
@ -103,7 +91,6 @@
public string CapacityLimit { get; set; } = ""; public string CapacityLimit { get; set; } = "";
public string ActivitySeconds { get; set; } = ""; public string ActivitySeconds { get; set; } = "";
public string ExpirationSeconds { get; set; } = ""; public string ExpirationSeconds { get; set; } = "";
public string CacheMilliseconds { get; set; } = "";
public bool RollingExpiration { get; set; } public bool RollingExpiration { get; set; }
} }
} }

View File

@ -4,7 +4,7 @@
@using AccessQueueService.Models; @using AccessQueueService.Models;
@using BlazorBootstrap @using BlazorBootstrap
@inject AccessQueueManager Manager @inject IAccessQueueManager Manager
<PageTitle>AccessQueue Playground</PageTitle> <PageTitle>AccessQueue Playground</PageTitle>
@if (Config != null) @if (Config != null)

View File

@ -19,17 +19,9 @@ builder.Host.UseSerilog((context, services, configuration) =>
// Add services to the container. // Add services to the container.
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
if (string.IsNullOrEmpty(builder.Configuration["AccessQueuePlayground:ServiceUrl"]))
{
builder.Services.AddSingleton<IAccessService, AccessService>(); builder.Services.AddSingleton<IAccessService, AccessService>();
} builder.Services.AddSingleton<IAccessQueueRepo, TakeANumberAccessQueueRepo>();
else builder.Services.AddSingleton<IAccessQueueManager, AccessQueueManager>();
{
builder.Services.AddHttpClient();
builder.Services.AddSingleton<IAccessService, AccessQueueApiService>();
}
builder.Services.AddSingleton<AccessQueueRepository>();
builder.Services.AddSingleton<AccessQueueManager>();
builder.Services.AddHostedService<AccessQueueBackgroundService>(); builder.Services.AddHostedService<AccessQueueBackgroundService>();
var app = builder.Build(); var app = builder.Build();

View File

@ -1,59 +0,0 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using AccessQueueService.Models;
using AccessQueueService.Services;
namespace AccessQueuePlayground.Services
{
public class AccessQueueApiService : IAccessService
{
private readonly HttpClient _httpClient;
private readonly string _serviceUrl;
public AccessQueueStatus Status => throw new NotImplementedException();
public AccessQueueApiService(HttpClient httpClient, IConfiguration config)
{
_httpClient = httpClient;
_serviceUrl = config["AccessQueuePlayground:ServiceUrl"]?.TrimEnd('/') ?? "https://localhost:7291";
}
public async Task<AccessResponse> RequestAccess(string userId)
{
return await _httpClient.GetFromJsonAsync<AccessResponse>($"{_serviceUrl}/access/{userId}");
}
public async Task<bool> RevokeAccess(string userId)
{
var response = await _httpClient.DeleteAsync($"{_serviceUrl}/access/{userId}");
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<bool>();
return result;
}
return false;
}
public Task<int> DeleteExpiredTickets()
{
throw new NotImplementedException();
}
public AccessQueueConfig GetConfiguration()
{
return _httpClient.GetFromJsonAsync<AccessQueueConfig>($"{_serviceUrl}/config").Result;
}
public void UpdateConfiguration(AccessQueueConfig config)
{
_ = _httpClient.PostAsJsonAsync($"{_serviceUrl}/config", config).Result;
}
public void PatchConfiguration(AccessQueueConfig partialConfig)
{
throw new NotImplementedException();
}
}
}

View File

@ -6,10 +6,10 @@ namespace AccessQueuePlayground.Services
{ {
public class AccessQueueBackgroundService : BackgroundService public class AccessQueueBackgroundService : BackgroundService
{ {
private readonly AccessQueueManager _accessQueueManager; private readonly IAccessQueueManager _accessQueueManager;
private readonly IConfiguration _config; private readonly IConfiguration _config;
public AccessQueueBackgroundService(AccessQueueManager accessQueueManager, IConfiguration config) public AccessQueueBackgroundService(IAccessQueueManager accessQueueManager, IConfiguration config)
{ {
_accessQueueManager = accessQueueManager; _accessQueueManager = accessQueueManager;
_config = config; _config = config;

View File

@ -6,7 +6,7 @@ using Microsoft.Extensions.Configuration;
namespace AccessQueuePlayground.Services namespace AccessQueuePlayground.Services
{ {
public class AccessQueueManager public class AccessQueueManager : IAccessQueueManager
{ {
private readonly IAccessService _accessService; private readonly IAccessService _accessService;
private readonly IConfiguration _config; private readonly IConfiguration _config;

View File

@ -0,0 +1,20 @@
using AccessQueuePlayground.Models;
using AccessQueueService.Models;
namespace AccessQueuePlayground.Services
{
public interface IAccessQueueManager
{
public event Action? StatusUpdated;
public AccessQueueConfig GetConfig();
public void UpdateConfig(AccessQueueConfig config);
public Task RecalculateStatus();
public AccessQueueManagerStatus GetStatus();
public Guid AddUser(bool isActive);
public void SetUserActive(Guid userId, bool isActive);
public void RevokeAccess(Guid userId);
public void RevokeAllAccess();
public void Reset();
}
}

View File

@ -6,14 +6,13 @@
} }
}, },
"AccessQueue": { "AccessQueue": {
"CapacityLimit": 3, "CapacityLimit": 3, // Maximum number of active users
"ActivitySeconds": 2, "ActivitySeconds": 2, // Time since last access before a user is considered inactive
"ExpirationSeconds": 10, "ExpirationSeconds": 10, // 12 hours - Time before a user access is revoked
"RollingExpiration": true "RollingExpiration": true // Whether to extend expiration time on access
}, },
"AccessQueuePlayground": { "AccessQueuePlayground": {
"RefreshRateMilliseconds": 200, "RefreshRateMilliseconds": 200 // How often to re-request access and update the UI
"ServiceUrl": "https://localhost:7291/"
}, },
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

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

View File

@ -6,7 +6,7 @@ using Microsoft.Extensions.Configuration;
namespace AccessQueueService.Data namespace AccessQueueService.Data
{ {
public class AccessQueueRepository public class TakeANumberAccessQueueRepo : IAccessQueueRepo
{ {
private ConcurrentDictionary<string, AccessTicket> _accessTickets = new(); private ConcurrentDictionary<string, AccessTicket> _accessTickets = new();
private ConcurrentDictionary<string, ulong> _queueNumbers = new(); private ConcurrentDictionary<string, ulong> _queueNumbers = new();
@ -15,9 +15,6 @@ namespace AccessQueueService.Data
internal ulong _nowServing = 0; internal ulong _nowServing = 0;
internal ulong _nextUnusedTicket = 0; internal ulong _nextUnusedTicket = 0;
private int? _cachedActiveUsers = null;
private DateTime _cachedActiveUsersTime = DateTime.MinValue;
public int GetUnexpiredTicketsCount() => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow); public int GetUnexpiredTicketsCount() => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow);
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);
@ -61,80 +58,37 @@ namespace AccessQueueService.Data
return count; return count;
} }
private bool HasValidActiveUsersCache(AccessQueueConfig config, DateTime now) public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit)
{ {
return config.CacheMilliseconds.HasValue && _cachedActiveUsers.HasValue && (now - _cachedActiveUsersTime).TotalMilliseconds < config.CacheMilliseconds.Value; 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;
if (openSpots <= 0)
{
return true;
} }
private int GetOpenSpots(AccessQueueConfig config, int activeUsers)
{
return config.CapacityLimit.Value - activeUsers;
}
private int UpdateActiveUsersCache(DateTime now, DateTime activeCutoff)
{
_cachedActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > now && t.Value.LastActive > activeCutoff);
_cachedActiveUsersTime = now;
return _cachedActiveUsers.GetValueOrDefault();
}
private int DequeueUsersUntilFull(int openSpots, DateTime now, DateTime activeCutoff, AccessQueueConfig config)
{
int filledSpots = 0; int filledSpots = 0;
while (filledSpots < openSpots && _nowServing < _nextUnusedTicket) while (filledSpots < openSpots && _nowServing < _nextUnusedTicket)
{ {
if (_accessQueue.TryRemove(_nowServing++, out var nextUser)) if (_accessQueue.TryRemove(_nowServing, out var nextUser))
{ {
_queueNumbers.TryRemove(nextUser.UserId, out _); _queueNumbers.TryRemove(nextUser.UserId, out _);
if (nextUser.LastActive < activeCutoff) if (nextUser.LastActive < activeCutoff)
{ {
// User is inactive, throw away their ticket // User is inactive, throw away their ticket
_nowServing++;
continue; continue;
} }
_accessTickets[nextUser.UserId] = new AccessTicket _accessTickets[nextUser.UserId] = new AccessTicket
{ {
UserId = nextUser.UserId, UserId = nextUser.UserId,
ExpiresOn = now.AddSeconds(config.ExpirationSeconds ?? 0), ExpiresOn = now.AddSeconds(expirationSeconds),
LastActive = now LastActive = now
}; };
filledSpots++; filledSpots++;
} }
} _nowServing++;
return filledSpots;
}
public bool DidDequeueUntilFull(AccessQueueConfig config)
{
var now = DateTime.UtcNow;
if (!config.ActivitySeconds.HasValue || !config.ExpirationSeconds.HasValue || !config.CapacityLimit.HasValue)
{
throw new Exception("Required config values are not defined.");
}
var activeCutoff = now.AddSeconds(-config.ActivitySeconds.Value);
int activeUsers;
if (HasValidActiveUsersCache(config, now))
{
activeUsers = _cachedActiveUsers.GetValueOrDefault();
}
else
{
activeUsers = UpdateActiveUsersCache(now, activeCutoff);
}
var openSpots = GetOpenSpots(config, activeUsers);
if (openSpots <= 0)
{
return true;
}
int filledSpots = DequeueUsersUntilFull(openSpots, now, activeCutoff, config);
// Invalidate cache if any users were granted access
if (filledSpots > 0)
{
_cachedActiveUsers = null;
_cachedActiveUsersTime = DateTime.MinValue;
} }
return filledSpots == openSpots; return filledSpots == openSpots;
} }
@ -147,8 +101,6 @@ namespace AccessQueueService.Data
public void UpsertTicket(AccessTicket ticket) public void UpsertTicket(AccessTicket ticket)
{ {
_accessTickets[ticket.UserId] = ticket; _accessTickets[ticket.UserId] = ticket;
_cachedActiveUsers = null;
_cachedActiveUsersTime = DateTime.MinValue;
} }
public bool RemoveUser(string userId) public bool RemoveUser(string userId)
@ -189,7 +141,7 @@ namespace AccessQueueService.Data
return JsonSerializer.Serialize(state); return JsonSerializer.Serialize(state);
} }
public static AccessQueueRepository FromState(string stateJson) public static TakeANumberAccessQueueRepo FromState(string stateJson)
{ {
var state = JsonSerializer.Deserialize<TakeANumberAccessQueueRepoState?>(stateJson); var state = JsonSerializer.Deserialize<TakeANumberAccessQueueRepoState?>(stateJson);
if (state?.AccessTickets == null || state?.AccessQueue == null) if (state?.AccessTickets == null || state?.AccessQueue == null)

View File

@ -6,7 +6,6 @@ namespace AccessQueueService.Models
public int? ActivitySeconds { get; set; } public int? ActivitySeconds { get; set; }
public int? ExpirationSeconds { get; set; } public int? ExpirationSeconds { get; set; }
public bool? RollingExpiration { get; set; } public bool? RollingExpiration { get; set; }
public int? CacheMilliseconds { get; set; }
public AccessQueueConfig Clone() public AccessQueueConfig Clone()
{ {

View File

@ -17,7 +17,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<AccessQueueRepository>(sp => builder.Services.AddSingleton<IAccessQueueRepo>(sp =>
{ {
string? filePath = builder.Configuration.GetValue<string>("AccessQueue:BackupFilePath"); string? filePath = builder.Configuration.GetValue<string>("AccessQueue:BackupFilePath");
if (!string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath)) if (!string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath))
@ -25,14 +25,14 @@ builder.Services.AddSingleton<AccessQueueRepository>(sp =>
try try
{ {
var json = File.ReadAllText(filePath); var json = File.ReadAllText(filePath);
return AccessQueueRepository.FromState(json); return TakeANumberAccessQueueRepo.FromState(json);
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Failed to load state from {filePath}. Error message: {ex.Message}"); Console.WriteLine($"Failed to load state from {filePath}. Error message: {ex.Message}");
} }
} }
return new AccessQueueRepository(); return new TakeANumberAccessQueueRepo();
}); });
builder.Services.AddHostedService<AccessCleanupBackgroundService>(); builder.Services.AddHostedService<AccessCleanupBackgroundService>();
builder.Services.AddHostedService<AccessQueueSerializerService>(); builder.Services.AddHostedService<AccessQueueSerializerService>();

View File

@ -5,11 +5,11 @@ namespace AccessQueueService.Services
{ {
public class AccessQueueSerializerService : BackgroundService public class AccessQueueSerializerService : BackgroundService
{ {
private readonly AccessQueueRepository _accessRepo; private readonly IAccessQueueRepo _accessRepo;
private readonly IConfiguration _config; private readonly IConfiguration _config;
private readonly ILogger<AccessQueueSerializerService> _logger; private readonly ILogger<AccessQueueSerializerService> _logger;
public AccessQueueSerializerService(AccessQueueRepository accessRepo, IConfiguration config, ILogger<AccessQueueSerializerService> logger) public AccessQueueSerializerService(IAccessQueueRepo accessRepo, IConfiguration config, ILogger<AccessQueueSerializerService> logger)
{ {
_accessRepo = accessRepo; _accessRepo = accessRepo;
_config = config; _config = config;

View File

@ -8,12 +8,12 @@ namespace AccessQueueService.Services
public class AccessService : IAccessService public class AccessService : IAccessService
{ {
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly AccessQueueRepository _accessQueueRepo; private readonly IAccessQueueRepo _accessQueueRepo;
private readonly ILogger<AccessService> _logger; private readonly ILogger<AccessService> _logger;
private readonly SemaphoreSlim _queueLock = new(1, 1); private readonly SemaphoreSlim _queueLock = new(1, 1);
private AccessQueueConfig _config; private AccessQueueConfig _config;
public AccessService(IConfiguration configuration, AccessQueueRepository accessQueueRepo, ILogger<AccessService> logger) public AccessService(IConfiguration configuration, IAccessQueueRepo accessQueueRepo, ILogger<AccessService> logger)
{ {
_configuration = configuration; _configuration = configuration;
_accessQueueRepo = accessQueueRepo; _accessQueueRepo = accessQueueRepo;
@ -23,8 +23,7 @@ namespace AccessQueueService.Services
ExpirationSeconds = _configuration.GetValue<int>("AccessQueue:ExpirationSeconds"), ExpirationSeconds = _configuration.GetValue<int>("AccessQueue:ExpirationSeconds"),
ActivitySeconds = _configuration.GetValue<int>("AccessQueue:ActivitySeconds"), ActivitySeconds = _configuration.GetValue<int>("AccessQueue:ActivitySeconds"),
CapacityLimit = _configuration.GetValue<int>("AccessQueue:CapacityLimit"), CapacityLimit = _configuration.GetValue<int>("AccessQueue:CapacityLimit"),
RollingExpiration = _configuration.GetValue<bool>("AccessQueue:RollingExpiration"), RollingExpiration = _configuration.GetValue<bool>("AccessQueue:RollingExpiration")
CacheMilliseconds = _configuration.GetValue<int>("AccessQueue:CacheMilliseconds")
}; };
} }
public AccessQueueConfig GetConfiguration() => _config.Clone(); public AccessQueueConfig GetConfiguration() => _config.Clone();
@ -54,7 +53,10 @@ namespace AccessQueueService.Services
await _queueLock.WaitAsync(); await _queueLock.WaitAsync();
try try
{ {
var hasCapacity = !_accessQueueRepo.DidDequeueUntilFull(_config); var hasCapacity = !_accessQueueRepo.DidDequeueUntilFull(
_config.ActivitySeconds.Value,
_config.ExpirationSeconds.Value,
_config.CapacityLimit.Value);
var existingTicket = _accessQueueRepo.GetTicket(userId); var existingTicket = _accessQueueRepo.GetTicket(userId);
if (existingTicket != null && existingTicket.ExpiresOn > DateTime.UtcNow) if (existingTicket != null && existingTicket.ExpiresOn > DateTime.UtcNow)
{ {
@ -107,10 +109,6 @@ namespace AccessQueueService.Services
}); });
_logger.LogInformation("User {UserId} added to queue. Requests ahead: {RequestsAhead}.", userId, requestsAhead); _logger.LogInformation("User {UserId} added to queue. Requests ahead: {RequestsAhead}.", userId, requestsAhead);
} }
else
{
_logger.LogInformation("User {UserId} already in queue. Requests ahead: {RequestsAhead}.", userId, requestsAhead);
}
return new AccessResponse return new AccessResponse
{ {
ExpiresOn = null, ExpiresOn = null,

View File

@ -30,8 +30,7 @@
"RollingExpiration": true, "RollingExpiration": true,
"CleanupIntervalSeconds": 60, "CleanupIntervalSeconds": 60,
"BackupFilePath": "Logs\\backup.json", "BackupFilePath": "Logs\\backup.json",
"BackupIntervalSeconds": 5, "BackupIntervalSeconds": 5
"CacheMilliseconds": 1000
}, },
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Sockets;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -14,44 +13,13 @@ namespace AccessQueueServiceTests
{ {
public class AccessQueueRepoTests public class AccessQueueRepoTests
{ {
private readonly AccessQueueRepository _repo; private readonly TakeANumberAccessQueueRepo _repo;
private readonly AccessQueueConfig _simpleConfig = new()
{
ExpirationSeconds = 60,
ActivitySeconds = 60,
CapacityLimit = 1,
RollingExpiration = false,
CacheMilliseconds = null
};
private readonly AccessQueueConfig _configWithCache = new()
{
ExpirationSeconds = 60,
ActivitySeconds = 60,
CapacityLimit = 1,
RollingExpiration = false,
CacheMilliseconds = 100
};
private AccessQueueConfig SimpleConfigWithCapacity(int capacity) => new()
{
ExpirationSeconds = 60,
ActivitySeconds = 60,
CapacityLimit = capacity,
RollingExpiration = false,
CacheMilliseconds = null
};
public AccessQueueRepoTests() public AccessQueueRepoTests()
{ {
_repo = new AccessQueueRepository(); _repo = new TakeANumberAccessQueueRepo();
} }
private List<AccessTicket> GetSimpleTicketList() => new()
{
new() { UserId = "first", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow },
new() { UserId = "second", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow },
new() { UserId = "third", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow }
};
[Fact] [Fact]
public void GetUnexpiredTicketsCount_ReturnsCorrectCount() public void GetUnexpiredTicketsCount_ReturnsCorrectCount()
{ {
@ -123,7 +91,7 @@ namespace AccessQueueServiceTests
var ticket = new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow }; var ticket = new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
_repo.Enqueue(ticket); _repo.Enqueue(ticket);
bool result = _repo.DidDequeueUntilFull(_simpleConfig); bool result = _repo.DidDequeueUntilFull(60, 60, 1);
Assert.True(result); Assert.True(result);
Assert.NotNull(_repo.GetTicket("a")); Assert.NotNull(_repo.GetTicket("a"));
} }
@ -133,7 +101,7 @@ namespace AccessQueueServiceTests
{ {
_repo.UpsertTicket(new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow }); _repo.UpsertTicket(new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow });
bool result = _repo.DidDequeueUntilFull(SimpleConfigWithCapacity(0)); bool result = _repo.DidDequeueUntilFull(60, 60, 0);
Assert.True(result); Assert.True(result);
} }
@ -187,7 +155,7 @@ namespace AccessQueueServiceTests
var active = new AccessTicket { UserId = "active", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow }; var active = new AccessTicket { UserId = "active", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
_repo.Enqueue(inactive); _repo.Enqueue(inactive);
_repo.Enqueue(active); _repo.Enqueue(active);
bool result = _repo.DidDequeueUntilFull(_simpleConfig); bool result = _repo.DidDequeueUntilFull(5 * 60, 60, 1);
Assert.True(result); Assert.True(result);
Assert.Null(_repo.GetTicket("inactive")); Assert.Null(_repo.GetTicket("inactive"));
Assert.NotNull(_repo.GetTicket("active")); Assert.NotNull(_repo.GetTicket("active"));
@ -196,10 +164,12 @@ namespace AccessQueueServiceTests
[Fact] [Fact]
public void Enqueue_QueuesUsersInOrder() public void Enqueue_QueuesUsersInOrder()
{ {
foreach (var ticket in GetSimpleTicketList()) var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow };
{ var ticket2 = new AccessTicket { UserId = "second", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow };
_repo.Enqueue(ticket); var ticket3 = new AccessTicket { UserId = "third", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow };
} _repo.Enqueue(ticket1);
_repo.Enqueue(ticket2);
_repo.Enqueue(ticket3);
Assert.Equal(0, _repo.GetRequestsAhead("first")); Assert.Equal(0, _repo.GetRequestsAhead("first"));
Assert.Equal(1, _repo.GetRequestsAhead("second")); Assert.Equal(1, _repo.GetRequestsAhead("second"));
Assert.Equal(2, _repo.GetRequestsAhead("third")); Assert.Equal(2, _repo.GetRequestsAhead("third"));
@ -208,12 +178,14 @@ namespace AccessQueueServiceTests
[Fact] [Fact]
public void DidDequeueUntilFull_DequeuesUsersInOrder() public void DidDequeueUntilFull_DequeuesUsersInOrder()
{ {
foreach (var ticket in GetSimpleTicketList()) var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
{ var ticket2 = new AccessTicket { UserId = "second", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
_repo.Enqueue(ticket); var ticket3 = new AccessTicket { UserId = "third", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
} _repo.Enqueue(ticket1);
_repo.Enqueue(ticket2);
_repo.Enqueue(ticket3);
bool result = _repo.DidDequeueUntilFull(_simpleConfig); bool result = _repo.DidDequeueUntilFull(60 * 60, 60, 1);
Assert.True(result); Assert.True(result);
Assert.NotNull(_repo.GetTicket("first")); Assert.NotNull(_repo.GetTicket("first"));
@ -221,33 +193,17 @@ namespace AccessQueueServiceTests
Assert.Null(_repo.GetTicket("third")); Assert.Null(_repo.GetTicket("third"));
} }
[Fact]
public void DidDequeuedUntilFull_CachesActiveTickets_WhenCacheMillisSet()
{
foreach (var ticket in GetSimpleTicketList())
{
_repo.Enqueue(ticket);
}
Assert.True(_repo.DidDequeueUntilFull(_configWithCache));
Assert.NotNull(_repo.GetTicket("first"));
Assert.Null(_repo.GetTicket("second"));
Assert.Null(_repo.GetTicket("third"));
Assert.True(_repo.DidDequeueUntilFull(_configWithCache));
Assert.Null(_repo.GetTicket("second"));
Assert.Null(_repo.GetTicket("third"));
}
[Fact] [Fact]
public void Optimize_MaintainsQueueOrder() public void Optimize_MaintainsQueueOrder()
{ {
foreach (var ticket in GetSimpleTicketList()) var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
{ var ticket2 = new AccessTicket { UserId = "second", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
_repo.Enqueue(ticket); var ticket3 = new AccessTicket { UserId = "third", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
} _repo.Enqueue(ticket1);
_repo.Enqueue(ticket2);
_repo.Enqueue(ticket3);
_repo.DidDequeueUntilFull(_simpleConfig); _repo.DidDequeueUntilFull(60 * 60, 60, 1);
_repo.Optimize(); _repo.Optimize();
Assert.NotNull(_repo.GetTicket("first")); Assert.NotNull(_repo.GetTicket("first"));
@ -255,7 +211,7 @@ namespace AccessQueueServiceTests
Assert.Equal(1, _repo.GetRequestsAhead("third")); Assert.Equal(1, _repo.GetRequestsAhead("third"));
Assert.Equal(0ul, _repo._nowServing); Assert.Equal(0ul, _repo._nowServing);
_repo.DidDequeueUntilFull(SimpleConfigWithCapacity(2)); _repo.DidDequeueUntilFull(60 * 60, 60, 2);
Assert.NotNull(_repo.GetTicket("second")); Assert.NotNull(_repo.GetTicket("second"));
Assert.Equal(0, _repo.GetRequestsAhead("third")); Assert.Equal(0, _repo.GetRequestsAhead("third"));
} }
@ -263,13 +219,16 @@ namespace AccessQueueServiceTests
[Fact] [Fact]
public void Enqueue_MaintainsQueueOrder_WhenMaxValueExceeded() public void Enqueue_MaintainsQueueOrder_WhenMaxValueExceeded()
{ {
var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
var ticket2 = new AccessTicket { UserId = "second", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
var ticket3 = new AccessTicket { UserId = "third", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
_repo._nowServing = long.MaxValue - 1; _repo._nowServing = long.MaxValue - 1;
_repo._nextUnusedTicket = long.MaxValue - 1; _repo._nextUnusedTicket = long.MaxValue - 1;
foreach (var ticket in GetSimpleTicketList()) _repo.Enqueue(ticket1);
{ _repo.Enqueue(ticket2);
_repo.Enqueue(ticket); _repo.Enqueue(ticket3);
}
Assert.Equal(0ul, _repo._nowServing); Assert.Equal(0ul, _repo._nowServing);
Assert.Equal(3ul, _repo._nextUnusedTicket); Assert.Equal(3ul, _repo._nextUnusedTicket);
@ -313,7 +272,7 @@ namespace AccessQueueServiceTests
_repo.Enqueue(ticketWithoutAccess); _repo.Enqueue(ticketWithoutAccess);
string stateJson = _repo.ToState(); string stateJson = _repo.ToState();
var deserializedRepo = AccessQueueRepository.FromState(stateJson); var deserializedRepo = TakeANumberAccessQueueRepo.FromState(stateJson);
Assert.Equal(1, deserializedRepo.GetUnexpiredTicketsCount()); Assert.Equal(1, deserializedRepo.GetUnexpiredTicketsCount());
Assert.Equal(1, deserializedRepo.GetQueueCount()); Assert.Equal(1, deserializedRepo.GetQueueCount());

View File

@ -31,7 +31,7 @@ namespace AccessQueueServiceTests
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(inMemorySettings) .AddInMemoryCollection(inMemorySettings)
.Build(); .Build();
var accessQueueRepo = new AccessQueueRepository(); var accessQueueRepo = new TakeANumberAccessQueueRepo();
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
var logger = loggerFactory.CreateLogger<AccessService>(); var logger = loggerFactory.CreateLogger<AccessService>();
_accessService = new AccessService(configuration, accessQueueRepo, logger); _accessService = new AccessService(configuration, accessQueueRepo, logger);

View File

@ -78,19 +78,13 @@ It is possible for the number of users with access to temporarily exceed the `Ca
Configuration is set in `appsettings.json` or via environment variables. The main configuration section is `AccessQueue`: Configuration is set in `appsettings.json` or via environment variables. The main configuration section is `AccessQueue`:
**Required variables:** - **CapacityLimit**: The maximum number of users that can have access at the same time (default: 100).
- **ActivitySeconds**: How long (in seconds) a user can remain active before being considered inactive (default: 900).
- **CapacityLimit**: The maximum number of users that can have access at the same time. - **ExpirationSeconds**: How long (in seconds) before an access ticket expires (default: 43200).
- **ActivitySeconds**: How long (in seconds) a user can remain active before being considered inactive. - **RollingExpiration**: If true, the expiration timer resets on activity (default: true).
- **ExpirationSeconds**: How long (in seconds) before an access ticket expires. - **CleanupIntervalSeconds**: How often (in seconds) the background cleanup runs to remove expired/inactive users (default: 60).
- **RollingExpiration**: If true, the expiration timer resets on activity. - **BackupFilePath**: The file path where the access queue state will be periodically saved (no default; optional).
- **CleanupIntervalSeconds**: How often (in seconds) the background cleanup runs to remove expired/inactive users. - **BackupIntervalSeconds**: How often (in seconds) the state is backed up to disk (no default; optional).
**Optional variables:**
- **BackupFilePath**: The file path where the access queue state will be periodically saved.
- **BackupIntervalSeconds**: How often (in seconds) the state is backed up to disk.
- **CacheMilliseconds**: How long (in milliseconds) to cache the active user count before recalculating. Lower values mean more frequent recalculation (more accurate, higher CPU usage). Higher values improve performance but may cause a slight delay in recognizing open spots.
Example `appsettings.json`: Example `appsettings.json`:
```json ```json
@ -100,8 +94,7 @@ Example `appsettings.json`:
"ActivitySeconds": 900, "ActivitySeconds": 900,
"ExpirationSeconds": 43200, "ExpirationSeconds": 43200,
"RollingExpiration": true, "RollingExpiration": true,
"CleanupIntervalSeconds": 60, "CleanupIntervalSeconds": 60
"CacheMilliseconds": 1000
} }
} }
``` ```
@ -162,7 +155,7 @@ Test results will be displayed in the terminal. You can also use Visual Studio's
The `AccessQueuePlayground` project provides a simple web-based UI for interacting with the AccessQueueService API. This is useful for testing and demonstration purposes. The `AccessQueuePlayground` project provides a simple web-based UI for interacting with the AccessQueueService API. This is useful for testing and demonstration purposes.
### Running ### Running the Playground
1. Build and run the playground project: 1. Build and run the playground project:
```powershell ```powershell
@ -173,7 +166,7 @@ The `AccessQueuePlayground` project provides a simple web-based UI for interacti
- HTTPS: https://localhost:7211 - HTTPS: https://localhost:7211
(See `AccessQueuePlayground/Properties/launchSettings.json` for details.) (See `AccessQueuePlayground/Properties/launchSettings.json` for details.)
### Using ### Using the Playground
- Open the provided URL in your browser. - Open the provided URL in your browser.
- Use the UI to request and revoke access for different user IDs. - Use the UI to request and revoke access for different user IDs.
@ -181,32 +174,5 @@ The `AccessQueuePlayground` project provides a simple web-based UI for interacti
This playground is intended only for local development and demonstration. This playground is intended only for local development and demonstration.
### Configuring
The `AccessQueuePlayground` project can be configured via its `appsettings.json` file. The main options are:
- **RefreshRateMilliseconds**: Determines how often (in milliseconds) the playground requests access for all active users and updates the UI. Lower values provide more real-time updates but may increase load on the service.
- **ServiceUrl**: The URL of the AccessQueueService API to use. If this is set, all API requests from the playground will be sent to the specified service URL (e.g., `https://localhost:7291/`).
- If `ServiceUrl` is **not provided**, the playground will use an internal instance of AccessQueueService, configured using the playground's own `appsettings.json` values under the `AccessQueue` section. This is useful for local development and testing without running the service separately.
Example configuration in `AccessQueuePlayground/appsettings.json`:
```json
{
"AccessQueuePlayground": {
"RefreshRateMilliseconds": 200,
"ServiceUrl": "https://localhost:7291/"
},
"AccessQueue": {
"CapacityLimit": 3,
"ActivitySeconds": 2,
"ExpirationSeconds": 10,
"RollingExpiration": true
}
}
```
> **Tip:** Adjust `RefreshRateMilliseconds` for your use case. For most demos, 100500ms works well. If you are connecting to a remote or production AccessQueueService, consider increasing the refresh interval (e.g., 1000ms or higher) to account for network latency and reduce unnecessary load.
## License ## License
See [LICENSE.txt](./LICENSE.txt) for license information. See [LICENSE.txt](./LICENSE.txt) for license information.