Compare commits
No commits in common. "main" and "restore-state" have entirely different histories.
main
...
restore-st
|
@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,15 +19,7 @@ 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>();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.Services.AddHttpClient();
|
|
||||||
builder.Services.AddSingleton<IAccessService, AccessQueueApiService>();
|
|
||||||
}
|
|
||||||
builder.Services.AddSingleton<IAccessQueueRepo, TakeANumberAccessQueueRepo>();
|
builder.Services.AddSingleton<IAccessQueueRepo, TakeANumberAccessQueueRepo>();
|
||||||
builder.Services.AddSingleton<IAccessQueueManager, AccessQueueManager>();
|
builder.Services.AddSingleton<IAccessQueueManager, AccessQueueManager>();
|
||||||
builder.Services.AddHostedService<AccessQueueBackgroundService>();
|
builder.Services.AddHostedService<AccessQueueBackgroundService>();
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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": "*"
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,6 @@ namespace AccessQueueService.Data
|
||||||
public void Enqueue(AccessTicket ticket);
|
public void Enqueue(AccessTicket ticket);
|
||||||
public int DeleteExpiredTickets();
|
public int DeleteExpiredTickets();
|
||||||
public bool RemoveUser(string userId);
|
public bool RemoveUser(string userId);
|
||||||
public bool DidDequeueUntilFull(AccessQueueConfig config);
|
public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
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)
|
||||||
|
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -23,15 +23,14 @@
|
||||||
"Application": "AccessQueueService"
|
"Application": "AccessQueueService"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AccessQueue": {
|
"AccessQueue": {
|
||||||
"CapacityLimit": 100,
|
"CapacityLimit": 100,
|
||||||
"ActivitySeconds": 900,
|
"ActivitySeconds": 900,
|
||||||
"ExpirationSeconds": 43200,
|
"ExpirationSeconds": 43200,
|
||||||
"RollingExpiration": true,
|
"RollingExpiration": true,
|
||||||
"CleanupIntervalSeconds": 60,
|
"CleanupIntervalSeconds": 60,
|
||||||
"BackupFilePath": "Logs\\backup.json",
|
"BackupFilePath": "Logs\\backup.json",
|
||||||
"BackupIntervalSeconds": 5,
|
"BackupIntervalSeconds": 5
|
||||||
"CacheMilliseconds": 1000
|
},
|
||||||
},
|
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
@ -15,43 +14,12 @@ namespace AccessQueueServiceTests
|
||||||
public class AccessQueueRepoTests
|
public class AccessQueueRepoTests
|
||||||
{
|
{
|
||||||
private readonly TakeANumberAccessQueueRepo _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 TakeANumberAccessQueueRepo();
|
_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);
|
||||||
|
|
|
@ -16,7 +16,6 @@ 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 = 10000;
|
const int BULK_COUNT = 10000;
|
||||||
const int CACHE_MILLIS = 1000;
|
|
||||||
private readonly AccessService _accessService;
|
private readonly AccessService _accessService;
|
||||||
|
|
||||||
public AccessServiceTests()
|
public AccessServiceTests()
|
||||||
|
@ -26,8 +25,7 @@ namespace AccessQueueServiceTests
|
||||||
{ "AccessQueue:ExpirationSeconds", $"{EXP_SECONDS}" },
|
{ "AccessQueue:ExpirationSeconds", $"{EXP_SECONDS}" },
|
||||||
{ "AccessQueue:ActivitySeconds", $"{ACT_SECONDS}" },
|
{ "AccessQueue:ActivitySeconds", $"{ACT_SECONDS}" },
|
||||||
{ "AccessQueue:CapacityLimit", $"{CAP_LIMIT}" },
|
{ "AccessQueue:CapacityLimit", $"{CAP_LIMIT}" },
|
||||||
{ "AccessQueue:RollingExpiration", "true" },
|
{ "AccessQueue:RollingExpiration", "true" }
|
||||||
{ "AccessQueue:CacheMilliseconds", $"{CACHE_MILLIS}" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var configuration = new ConfigurationBuilder()
|
var configuration = new ConfigurationBuilder()
|
||||||
|
@ -89,9 +87,9 @@ namespace AccessQueueServiceTests
|
||||||
Assert.NotNull(response);
|
Assert.NotNull(response);
|
||||||
Assert.Null(response.ExpiresOn);
|
Assert.Null(response.ExpiresOn);
|
||||||
Assert.True(response.RequestsAhead == CAP_LIMIT);
|
Assert.True(response.RequestsAhead == CAP_LIMIT);
|
||||||
Assert.Equal(CAP_LIMIT, _accessService.UnexpiredTicketsCount);
|
Assert.Equal(5, _accessService.UnexpiredTicketsCount);
|
||||||
Assert.Equal(CAP_LIMIT, _accessService.ActiveTicketsCount);
|
Assert.Equal(5, _accessService.ActiveTicketsCount);
|
||||||
Assert.Equal(CAP_LIMIT + 1, _accessService.QueueCount);
|
Assert.Equal(6, _accessService.QueueCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
54
README.md
54
README.md
|
@ -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, 100–500ms 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.
|
Loading…
Reference in New Issue