Compare commits
14 Commits
calculate-
...
main
Author | SHA1 | Date |
---|---|---|
|
fbbd5933ed | |
|
98f60a996a | |
|
2484f6674b | |
|
f0c54026c4 | |
|
50ae4119d6 | |
|
3ba808558d | |
|
0311dc2b7d | |
|
913a2441ed | |
|
6afc30ae61 | |
|
3d2a17ec68 | |
|
fbf2d43174 | |
|
4feed0fab9 | |
|
2000eaed99 | |
|
f212deca59 |
|
@ -1,6 +1,33 @@
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="/">AccessQueue Playground</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/">Home</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/about">About</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/config">Config</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="https://git.hobbs.zone/henry/AccessQueueService" target="_blank" rel="noopener">Source</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-body">
|
||||||
@Body
|
@Body
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="blazor-error-ui">
|
<div id="blazor-error-ui">
|
||||||
An unhandled error has occurred.
|
An unhandled error has occurred.
|
||||||
|
|
|
@ -16,3 +16,10 @@
|
||||||
right: 0.75rem;
|
right: 0.75rem;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container-body {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
@page "/about"
|
||||||
|
|
||||||
|
<PageTitle>About - AccessQueue Playground</PageTitle>
|
||||||
|
|
||||||
|
<h2>About AccessQueue Playground</h2>
|
||||||
|
<p>
|
||||||
|
<b>AccessQueue Playground</b> is a demo Blazor application for testing and visualizing the <b>AccessQueueService</b> system. It allows you to simulate users requesting access, manage a queue, and experiment with configuration options such as expiration, activity, and capacity limits.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Project: <b>AccessQueueService</b></li>
|
||||||
|
<li>Frontend: <b>Blazor (BlazorBootstrap)</b></li>
|
||||||
|
<li>Backend: <b>.NET 8</b></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>How AccessQueueService Works</h3>
|
||||||
|
<ol>
|
||||||
|
<li><b>Requesting Access:</b> Users are granted access if capacity is available, otherwise they are queued. Expiration and activity timeouts are enforced.</li>
|
||||||
|
<li><b>Queueing:</b> Users in the queue must remain active to keep their spot. The queue is managed FIFO (first-in, first-out).</li>
|
||||||
|
<li><b>Dequeuing:</b> When capacity is available, users are dequeued and granted access if still active.</li>
|
||||||
|
<li><b>Maintaining Access:</b> Users must re-request access to remain active. Expiration can be rolling or fixed.</li>
|
||||||
|
<li><b>Revoking Access:</b> Users can revoke their access, freeing up capacity for others.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Configuration Options</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>CapacityLimit:</b> Max concurrent users with access.</li>
|
||||||
|
<li><b>ActivitySeconds:</b> How long a user can be inactive before losing their spot.</li>
|
||||||
|
<li><b>ExpirationSeconds:</b> How long before an access ticket expires.</li>
|
||||||
|
<li><b>RollingExpiration:</b> If true, expiration resets on activity.</li>
|
||||||
|
<li><b>RefreshRateMilliseconds:</b> How often the playground requests access for active users and updates the UI.</li>
|
||||||
|
</ul>
|
||||||
|
<p>For now these options can only be set via appsettings.json. The Playground UI does not yet support changing these values.</p>
|
||||||
|
|
||||||
|
<h3>About the Playground UI</h3>
|
||||||
|
<p>
|
||||||
|
The Playground UI lets you:
|
||||||
|
<ul>
|
||||||
|
<li>Add users to the queue and grant access.</li>
|
||||||
|
<li>Revoke access for individual users or all users.</li>
|
||||||
|
<li>Reset all data for testing.</li>
|
||||||
|
<li>See real-time updates of users with access, in queue, and inactive.</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Source Code & Documentation</h3>
|
||||||
|
<p>
|
||||||
|
See the <a href="https://git.hobbs.zone/henry/AccessQueueService" target="_blank" rel="noopener">project repository</a> for full documentation, source code, and usage instructions.
|
||||||
|
</p>
|
|
@ -0,0 +1,96 @@
|
||||||
|
@page "/config"
|
||||||
|
@inject AccessQueuePlayground.Services.IAccessQueueManager QueueManager
|
||||||
|
@using BlazorBootstrap
|
||||||
|
|
||||||
|
<h3>Access Queue Configuration</h3>
|
||||||
|
|
||||||
|
<EditForm Model="config" OnValidSubmit="HandleValidSubmit">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<ValidationSummary />
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="capacityLimit">Capacity Limit</label>
|
||||||
|
<TextInput Id="capacityLimit" @bind-Value="config.CapacityLimit" Type="TextInputType.Number" />
|
||||||
|
@if (!isCapacityLimitValid)
|
||||||
|
{
|
||||||
|
<div class="text-danger">Please enter a positive integer.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="activitySeconds">Activity Seconds</label>
|
||||||
|
<TextInput Id="activitySeconds" @bind-Value="config.ActivitySeconds" Type="TextInputType.Number" />
|
||||||
|
@if (!isActivitySecondsValid)
|
||||||
|
{
|
||||||
|
<div class="text-danger">Please enter a positive integer.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="expirationSeconds">Expiration Seconds</label>
|
||||||
|
<TextInput Id="expirationSeconds" @bind-Value="config.ExpirationSeconds" Type="TextInputType.Number" />
|
||||||
|
@if (!isExpirationSecondsValid)
|
||||||
|
{
|
||||||
|
<div class="text-danger">Please enter a positive integer.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<Switch Id="rollingExpiration" @bind-Value="config.RollingExpiration" Label="Rolling Expiration" />
|
||||||
|
</div>
|
||||||
|
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary">Save</Button>
|
||||||
|
@if (successMessage != null)
|
||||||
|
{
|
||||||
|
<Alert Color="AlertColor.Success" Class="mt-3">@successMessage</Alert>
|
||||||
|
}
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private ConfigModel config = new();
|
||||||
|
private bool isCapacityLimitValid = true;
|
||||||
|
private bool isActivitySecondsValid = true;
|
||||||
|
private bool isExpirationSecondsValid = true;
|
||||||
|
private string? successMessage;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
var current = QueueManager.GetConfig();
|
||||||
|
config = new ConfigModel
|
||||||
|
{
|
||||||
|
ActivitySeconds = (current.ActivitySeconds ?? 0).ToString(),
|
||||||
|
CapacityLimit = (current.CapacityLimit ?? 0).ToString(),
|
||||||
|
ExpirationSeconds = (current.ExpirationSeconds ?? 0).ToString(),
|
||||||
|
RollingExpiration = current.RollingExpiration ?? false
|
||||||
|
};
|
||||||
|
ValidateInputs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsFormValid => isCapacityLimitValid && isActivitySecondsValid && isExpirationSecondsValid;
|
||||||
|
|
||||||
|
private void ValidateInputs()
|
||||||
|
{
|
||||||
|
isCapacityLimitValid = int.TryParse(config.CapacityLimit, out var cap) && cap > 0;
|
||||||
|
isActivitySecondsValid = int.TryParse(config.ActivitySeconds, out var act) && act > 0;
|
||||||
|
isExpirationSecondsValid = int.TryParse(config.ExpirationSeconds, out var exp) && exp > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleValidSubmit()
|
||||||
|
{
|
||||||
|
successMessage = null;
|
||||||
|
ValidateInputs();
|
||||||
|
if (!IsFormValid)
|
||||||
|
return;
|
||||||
|
await Task.Run(() => QueueManager.UpdateConfig(new ()
|
||||||
|
{
|
||||||
|
ActivitySeconds = int.Parse(config.ActivitySeconds),
|
||||||
|
CapacityLimit = int.Parse(config.CapacityLimit),
|
||||||
|
ExpirationSeconds = int.Parse(config.ExpirationSeconds),
|
||||||
|
RollingExpiration = config.RollingExpiration
|
||||||
|
}));
|
||||||
|
successMessage = "Configuration updated successfully.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfigModel
|
||||||
|
{
|
||||||
|
public string CapacityLimit { get; set; } = "";
|
||||||
|
public string ActivitySeconds { get; set; } = "";
|
||||||
|
public string ExpirationSeconds { get; set; } = "";
|
||||||
|
public bool RollingExpiration { get; set; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
@page "/"
|
@page "/"
|
||||||
@using AccessQueuePlayground.Models
|
@using AccessQueuePlayground.Models
|
||||||
@using AccessQueuePlayground.Services
|
@using AccessQueuePlayground.Services
|
||||||
|
@using AccessQueueService.Models;
|
||||||
@using BlazorBootstrap
|
@using BlazorBootstrap
|
||||||
|
|
||||||
@inject IAccessQueueManager Manager
|
@inject IAccessQueueManager Manager
|
||||||
|
@ -12,17 +13,19 @@
|
||||||
<p>
|
<p>
|
||||||
<b>Expiration Seconds:</b> @Config.ExpirationSeconds,
|
<b>Expiration Seconds:</b> @Config.ExpirationSeconds,
|
||||||
<b>Activity Seconds:</b> @Config.ActivitySeconds,
|
<b>Activity Seconds:</b> @Config.ActivitySeconds,
|
||||||
<b>Capacity Limit:</b> @Config.CapacityLimit
|
<b>Capacity Limit:</b> @Config.CapacityLimit,
|
||||||
|
<b>Rolling Expiration:</b> @Config.RollingExpiration
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
<p>
|
<p>
|
||||||
<Button Color="ButtonColor.Success" @onclick="AddUser">Add User</Button>
|
<Button Color="ButtonColor.Success" @onclick="() => AddUser(true)">Add Active User</Button>
|
||||||
|
<Button Color="ButtonColor.Success" Outline @onclick="() => AddUser(false)">Add Inctive User</Button>
|
||||||
<Button Color="ButtonColor.Danger" @onclick="RevokeAllAccess">Revoke All</Button>
|
<Button Color="ButtonColor.Danger" @onclick="RevokeAllAccess">Revoke All</Button>
|
||||||
<Button Color="ButtonColor.Warning" @onclick="Reset">Reset Data</Button>
|
<Button Color="ButtonColor.Warning" @onclick="Reset">Reset Data</Button>
|
||||||
</p>
|
</p>
|
||||||
@if (Status != null)
|
@if (Status != null)
|
||||||
{
|
{
|
||||||
<h2>Users with access</h2>
|
<h4>Users with access</h4>
|
||||||
<Grid TItem="User" Data="Status.AccessUsers" Class="table table-bordered mt-3" AllowSorting>
|
<Grid TItem="User" Data="Status.AccessUsers" Class="table table-bordered mt-3" AllowSorting>
|
||||||
<GridColumns>
|
<GridColumns>
|
||||||
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id">
|
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id">
|
||||||
|
@ -43,7 +46,7 @@
|
||||||
</GridColumn>
|
</GridColumn>
|
||||||
</GridColumns>
|
</GridColumns>
|
||||||
</Grid>
|
</Grid>
|
||||||
<h2>Users in queue</h2>
|
<h4>Users in queue</h4>
|
||||||
<Grid TItem="User" Data="Status.QueuedUsers" Class="table table-bordered mt-3">
|
<Grid TItem="User" Data="Status.QueuedUsers" Class="table table-bordered mt-3">
|
||||||
<GridColumns>
|
<GridColumns>
|
||||||
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id">
|
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id">
|
||||||
|
@ -64,7 +67,7 @@
|
||||||
</GridColumn>
|
</GridColumn>
|
||||||
</GridColumns>
|
</GridColumns>
|
||||||
</Grid>
|
</Grid>
|
||||||
<h2>Inactive users</h2>
|
<h4>Inactive users</h4>
|
||||||
<Grid TItem="User" Data="Status.InactiveUsers" Class="table table-bordered mt-3" AllowSorting>
|
<Grid TItem="User" Data="Status.InactiveUsers" Class="table table-bordered mt-3" AllowSorting>
|
||||||
<GridColumns>
|
<GridColumns>
|
||||||
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id">
|
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id">
|
||||||
|
@ -98,9 +101,9 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddUser()
|
public void AddUser(bool isActive)
|
||||||
{
|
{
|
||||||
Manager.AddUser();
|
Manager.AddUser(isActive);
|
||||||
Status = Manager.GetStatus();
|
Status = Manager.GetStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
namespace AccessQueuePlayground.Models
|
|
||||||
{
|
|
||||||
public class AccessQueueConfig
|
|
||||||
{
|
|
||||||
public int ActivitySeconds { get; set; }
|
|
||||||
public int ExpirationSeconds { get; set; }
|
|
||||||
public int CapacityLimit { get; set; }
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -29,20 +29,20 @@ namespace AccessQueuePlayground.Services
|
||||||
|
|
||||||
public AccessQueueStatus GetStatus() => _status;
|
public AccessQueueStatus GetStatus() => _status;
|
||||||
|
|
||||||
public AccessQueueConfig GetConfig() => new AccessQueueConfig
|
public AccessQueueConfig GetConfig() => _accessService.GetConfiguration();
|
||||||
{
|
|
||||||
ActivitySeconds = _config.GetValue<int>("AccessQueue:ActivitySeconds"),
|
|
||||||
ExpirationSeconds = _config.GetValue<int>("AccessQueue:ExpirationSeconds"),
|
|
||||||
CapacityLimit = _config.GetValue<int>("AccessQueue:CapacityLimit")
|
|
||||||
};
|
|
||||||
|
|
||||||
public Guid AddUser()
|
public void UpdateConfig(AccessQueueConfig config)
|
||||||
|
{
|
||||||
|
_accessService.UpdateConfiguration(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid AddUser(bool isActive)
|
||||||
{
|
{
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
_users[id] = new User
|
_users[id] = new User
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Active = false,
|
Active = isActive,
|
||||||
};
|
};
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using AccessQueuePlayground.Models;
|
using AccessQueuePlayground.Models;
|
||||||
|
using AccessQueueService.Models;
|
||||||
|
|
||||||
namespace AccessQueuePlayground.Services
|
namespace AccessQueuePlayground.Services
|
||||||
{
|
{
|
||||||
|
@ -6,9 +7,10 @@ namespace AccessQueuePlayground.Services
|
||||||
{
|
{
|
||||||
public event Action? StatusUpdated;
|
public event Action? StatusUpdated;
|
||||||
public AccessQueueConfig GetConfig();
|
public AccessQueueConfig GetConfig();
|
||||||
|
public void UpdateConfig(AccessQueueConfig config);
|
||||||
public Task RecalculateStatus();
|
public Task RecalculateStatus();
|
||||||
public AccessQueueStatus GetStatus();
|
public AccessQueueStatus GetStatus();
|
||||||
public Guid AddUser();
|
public Guid AddUser(bool isActive);
|
||||||
public void SetUserActive(Guid userId, bool isActive);
|
public void SetUserActive(Guid userId, bool isActive);
|
||||||
public void RevokeAccess(Guid userId);
|
public void RevokeAccess(Guid userId);
|
||||||
public void RevokeAllAccess();
|
public void RevokeAllAccess();
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
@AccessQueueService_HostAddress = http://localhost:5199
|
|
||||||
|
|
||||||
GET {{AccessQueueService_HostAddress}}/weatherforecast/
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
###
|
|
|
@ -28,5 +28,18 @@ namespace AccessQueueService.Controllers
|
||||||
{
|
{
|
||||||
return await _accessService.RevokeAccess(id);
|
return await _accessService.RevokeAccess(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("configuration")]
|
||||||
|
public ActionResult<AccessQueueConfig> GetConfiguration()
|
||||||
|
{
|
||||||
|
return Ok(_accessService.GetConfiguration());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("configuration")]
|
||||||
|
public IActionResult UpdateConfiguration([FromBody] AccessQueueConfig config)
|
||||||
|
{
|
||||||
|
_accessService.PatchConfiguration(config);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace AccessQueueService.Models
|
||||||
|
{
|
||||||
|
public class AccessQueueConfig
|
||||||
|
{
|
||||||
|
public int? CapacityLimit { get; set; }
|
||||||
|
public int? ActivitySeconds { get; set; }
|
||||||
|
public int? ExpirationSeconds { get; set; }
|
||||||
|
public bool? RollingExpiration { get; set; }
|
||||||
|
|
||||||
|
public AccessQueueConfig Clone()
|
||||||
|
{
|
||||||
|
return (AccessQueueConfig)this.MemberwiseClone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,37 +12,52 @@ namespace AccessQueueService.Services
|
||||||
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 readonly int EXP_SECONDS;
|
private AccessQueueConfig _config;
|
||||||
private readonly int ACT_SECONDS;
|
|
||||||
private readonly int CAPACITY_LIMIT;
|
|
||||||
private readonly bool ROLLING_EXPIRATION;
|
|
||||||
public AccessService(IConfiguration configuration, IAccessQueueRepo accessQueueRepo, ILogger<AccessService> logger)
|
public AccessService(IConfiguration configuration, IAccessQueueRepo accessQueueRepo, ILogger<AccessService> logger)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_accessQueueRepo = accessQueueRepo;
|
_accessQueueRepo = accessQueueRepo;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
EXP_SECONDS = _configuration.GetValue<int>("AccessQueue:ExpirationSeconds");
|
_config = new AccessQueueConfig
|
||||||
ACT_SECONDS = _configuration.GetValue<int>("AccessQueue:ActivitySeconds");
|
{
|
||||||
CAPACITY_LIMIT = _configuration.GetValue<int>("AccessQueue:CapacityLimit");
|
ExpirationSeconds = _configuration.GetValue<int>("AccessQueue:ExpirationSeconds"),
|
||||||
ROLLING_EXPIRATION = _configuration.GetValue<bool>("AccessQueue:RollingExpiration");
|
ActivitySeconds = _configuration.GetValue<int>("AccessQueue:ActivitySeconds"),
|
||||||
|
CapacityLimit = _configuration.GetValue<int>("AccessQueue:CapacityLimit"),
|
||||||
|
RollingExpiration = _configuration.GetValue<bool>("AccessQueue:RollingExpiration")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
public AccessQueueConfig GetConfiguration() => _config.Clone();
|
||||||
|
public void UpdateConfiguration(AccessQueueConfig config)
|
||||||
|
{
|
||||||
|
_config = config.Clone();
|
||||||
|
}
|
||||||
|
public void PatchConfiguration(AccessQueueConfig partialConfig)
|
||||||
|
{
|
||||||
|
if (partialConfig.CapacityLimit.HasValue) _config.CapacityLimit = partialConfig.CapacityLimit.Value;
|
||||||
|
if (partialConfig.ActivitySeconds.HasValue) _config.ActivitySeconds = partialConfig.ActivitySeconds.Value;
|
||||||
|
if (partialConfig.ExpirationSeconds.HasValue) _config.ExpirationSeconds = partialConfig.ExpirationSeconds.Value;
|
||||||
|
if (partialConfig.RollingExpiration.HasValue) _config.RollingExpiration = partialConfig.RollingExpiration.Value;
|
||||||
}
|
}
|
||||||
public int UnexpiredTicketsCount => _accessQueueRepo.GetUnexpiredTicketsCount();
|
public int UnexpiredTicketsCount => _accessQueueRepo.GetUnexpiredTicketsCount();
|
||||||
public int ActiveTicketsCount => _accessQueueRepo.GetActiveTicketsCount(DateTime.UtcNow.AddSeconds(-_configuration.GetValue<int>("AccessQueue:ActivitySeconds")));
|
public int ActiveTicketsCount => _accessQueueRepo.GetActiveTicketsCount(DateTime.UtcNow.AddSeconds(-_config.ActivitySeconds.Value));
|
||||||
public int QueueCount => _accessQueueRepo.GetQueueCount();
|
public int QueueCount => _accessQueueRepo.GetQueueCount();
|
||||||
public async Task<AccessResponse> RequestAccess(string userId)
|
public async Task<AccessResponse> RequestAccess(string userId)
|
||||||
{
|
{
|
||||||
await _queueLock.WaitAsync();
|
await _queueLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var hasCapacity = !_accessQueueRepo.DidDequeueUntilFull(ACT_SECONDS, EXP_SECONDS, CAPACITY_LIMIT);
|
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)
|
||||||
{
|
{
|
||||||
// Already has access
|
// Already has access
|
||||||
var expiresOn = existingTicket.ExpiresOn;
|
var expiresOn = existingTicket.ExpiresOn;
|
||||||
if (ROLLING_EXPIRATION)
|
if (_config.RollingExpiration.Value)
|
||||||
{
|
{
|
||||||
expiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS);
|
expiresOn = DateTime.UtcNow.AddSeconds(_config.ExpirationSeconds.Value);
|
||||||
}
|
}
|
||||||
_accessQueueRepo.UpsertTicket(new AccessTicket
|
_accessQueueRepo.UpsertTicket(new AccessTicket
|
||||||
{
|
{
|
||||||
|
@ -62,7 +77,7 @@ namespace AccessQueueService.Services
|
||||||
var accessTicket = new AccessTicket
|
var accessTicket = new AccessTicket
|
||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
ExpiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS),
|
ExpiresOn = DateTime.UtcNow.AddSeconds(_config.ExpirationSeconds.Value),
|
||||||
LastActive = DateTime.UtcNow
|
LastActive = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
_accessQueueRepo.UpsertTicket(accessTicket);
|
_accessQueueRepo.UpsertTicket(accessTicket);
|
||||||
|
|
|
@ -7,5 +7,8 @@ namespace AccessQueueService.Services
|
||||||
public Task<AccessResponse> RequestAccess(string userId);
|
public Task<AccessResponse> RequestAccess(string userId);
|
||||||
public Task<bool> RevokeAccess(string userId);
|
public Task<bool> RevokeAccess(string userId);
|
||||||
public Task<int> DeleteExpiredTickets();
|
public Task<int> DeleteExpiredTickets();
|
||||||
|
public AccessQueueConfig GetConfiguration();
|
||||||
|
public void UpdateConfiguration(AccessQueueConfig config);
|
||||||
|
public void PatchConfiguration(AccessQueueConfig partialConfig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) [year] [fullname]
|
Copyright (c) 2025 Henry Hobbs
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
138
README.md
138
README.md
|
@ -1 +1,139 @@
|
||||||
|
**NOTE:** I have added github as a remote but for the latest commits and issue tracking please visit https://git.hobbs.zone/henry/AccessQueueService
|
||||||
|
|
||||||
# AccessQueueService
|
# AccessQueueService
|
||||||
|
|
||||||
|
AccessQueueService is a microservice API designed to control access to a resource with a limited number of concurrent users. It ensures fair access by:
|
||||||
|
|
||||||
|
- Granting immediate access if capacity is available.
|
||||||
|
- Placing users in a queue when the resource is full.
|
||||||
|
- Automatically managing the queue in a first-in, first-out (FIFO) order.
|
||||||
|
- Allowing users to revoke their access, freeing up capacity for others.
|
||||||
|
|
||||||
|
This service is ideal for scenarios where you need to limit the number of users accessing a resource at the same time, such as online ticket sale platforms that control how many users can purchase tickets concurrently.
|
||||||
|
|
||||||
|
Note: This service is not intended to be called directly from end-user client applications, as it could be easily bypassed. Instead, it should be integrated as middleware within your own APIs or backend services.
|
||||||
|
|
||||||
|
## How the Service Works
|
||||||
|
|
||||||
|
1. **Requesting Access:**
|
||||||
|
- When a user requests access, the service checks if the current number of active users is below `CapacityLimit`.
|
||||||
|
- If there is capacity, the user is granted access immediately and receives an expiration date set by `ExpirationSeconds`.
|
||||||
|
- If not, the user is added to a queue and receives their position in the queue.
|
||||||
|
|
||||||
|
2. **Queueing:**
|
||||||
|
- If a user is placed in the queue, subsequent access requests will return the number of users ahead.
|
||||||
|
- Users must continually re-request access to remain active in the queue; inactivity may result in losing their spot.
|
||||||
|
|
||||||
|
3. **Dequeuing:**
|
||||||
|
- Users in the queue are managed in a FIFO (first-in, first-out) order.
|
||||||
|
- Whenever an access request is made, if there is capacity, the service attempts to dequeue users until capacity is met.
|
||||||
|
- If a user is dequeued but the time since their last activity is greater than `ActivitySeconds`, they are not granted access and lose their spot in the queue.
|
||||||
|
|
||||||
|
4. **Maintaining Access:**
|
||||||
|
- Users should continually re-request access while they are active to avoid being considered inactive.
|
||||||
|
- If `RollingExpiration` is enabled, the expiration is reset whenever access is re-requested.
|
||||||
|
|
||||||
|
5. **Revoking Access:**
|
||||||
|
- If a user requests access after their expiration date, they must restart the process and re-join the queue if there isn't capacity.
|
||||||
|
- When a user revokes access (or their access times out), their access expires immediately.
|
||||||
|
|
||||||
|
### Note on inactivity vs expiration
|
||||||
|
|
||||||
|
It is possible for the number of users with access to temporarily exceed the `CapacityLimit` if `ActivitySeconds` is less than `ExpirationSeconds`. This happens because:
|
||||||
|
|
||||||
|
- The number of available slots is determined by the time since a user's last activity (`ActivitySeconds`), not by their access expiration (`ExpirationSeconds`).
|
||||||
|
- If a user is inactive for longer than `ActivitySeconds`, they no longer count toward the capacity, allowing another user to gain access.
|
||||||
|
- However, the original user still technically has access until their `ExpirationSeconds` elapses.
|
||||||
|
|
||||||
|
**To ensure the number of users with access never exceeds `CapacityLimit`, set `ActivitySeconds` equal to `ExpirationSeconds`.**
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
### Request Access
|
||||||
|
- **GET /access/{id}**
|
||||||
|
- **Description:** Request access for a user with the specified `id`.
|
||||||
|
- **Response:** Returns an `AccessResponse` object indicating whether access was granted or the user's position in the queue.
|
||||||
|
|
||||||
|
### Revoke Access
|
||||||
|
- **DELETE /access/{id}**
|
||||||
|
- **Description:** Revoke access for a user with the specified `id`. This will remove the user from the active list or queue and may allow the next user in the queue to gain access.
|
||||||
|
- **Response:** Returns a boolean indicating success.
|
||||||
|
|
||||||
|
## Configuration Variables
|
||||||
|
|
||||||
|
Configuration is set in `appsettings.json` or via environment variables. The main configuration section is `AccessQueue`:
|
||||||
|
|
||||||
|
- **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).
|
||||||
|
- **ExpirationSeconds**: How long (in seconds) before an access ticket expires (default: 43200).
|
||||||
|
- **RollingExpiration**: If true, the expiration timer resets on activity (default: true).
|
||||||
|
- **CleanupIntervalSeconds**: How often (in seconds) the background cleanup runs to remove expired/inactive users (default: 60).
|
||||||
|
|
||||||
|
Example `appsettings.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"AccessQueue": {
|
||||||
|
"CapacityLimit": 100,
|
||||||
|
"ActivitySeconds": 900,
|
||||||
|
"ExpirationSeconds": 43200,
|
||||||
|
"RollingExpiration": true,
|
||||||
|
"CleanupIntervalSeconds": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## AccessResponse Object
|
||||||
|
|
||||||
|
The `AccessResponse` object returned by the API contains the following properties:
|
||||||
|
|
||||||
|
- **ExpiresOn** (`DateTime?`): The UTC timestamp when the user's access will expire. `null` if the user does not have access.
|
||||||
|
- **RequestsAhead** (`int`): The number of requests ahead of the user in the queue. `0` if the user has access.
|
||||||
|
- **HasAccess** (`bool`): Indicates whether the user currently has access (true if `ExpiresOn` is set and in the future).
|
||||||
|
|
||||||
|
## Running the Service
|
||||||
|
|
||||||
|
1. Build and run the project using .NET 8.0 or later:
|
||||||
|
```powershell
|
||||||
|
dotnet run --project AccessQueueService/AccessQueueService.csproj
|
||||||
|
```
|
||||||
|
2. By default, the API will be available at:
|
||||||
|
- HTTP: http://localhost:5199
|
||||||
|
- HTTPS: https://localhost:7291
|
||||||
|
(See `AccessQueueService/Properties/launchSettings.json` for details.)
|
||||||
|
|
||||||
|
## Running the Tests
|
||||||
|
|
||||||
|
Unit tests for the service are located in the `AccessQueueServiceTests` project. To run all tests, use the following command from the root of the repository:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Run all tests in the solution
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
Test results will be displayed in the terminal. You can also use Visual Studio's Test Explorer for a graphical interface.
|
||||||
|
|
||||||
|
## AccessQueuePlayground (Demo UI)
|
||||||
|
|
||||||
|
The `AccessQueuePlayground` project provides a simple web-based UI for interacting with the AccessQueueService API. This is useful for testing and demonstration purposes.
|
||||||
|
|
||||||
|
### Running the Playground
|
||||||
|
|
||||||
|
1. Build and run the playground project:
|
||||||
|
```powershell
|
||||||
|
dotnet run --project AccessQueuePlayground/AccessQueuePlayground.csproj
|
||||||
|
```
|
||||||
|
2. By default, the playground will be available at:
|
||||||
|
- HTTP: http://localhost:5108
|
||||||
|
- HTTPS: https://localhost:7211
|
||||||
|
(See `AccessQueuePlayground/Properties/launchSettings.json` for details.)
|
||||||
|
|
||||||
|
### Using the Playground
|
||||||
|
|
||||||
|
- Open the provided URL in your browser.
|
||||||
|
- Use the UI to request and revoke access for different user IDs.
|
||||||
|
- The UI will display your access status, queue position, and expiration time.
|
||||||
|
|
||||||
|
This playground is intended only for local development and demonstration.
|
||||||
|
|
||||||
|
## License
|
||||||
|
See [LICENSE.txt](./LICENSE.txt) for license information.
|
Loading…
Reference in New Issue