diff --git a/AccessQueueService/Data/IAccessQueueRepo.cs b/AccessQueueService/Data/IAccessQueueRepo.cs index 3f76d64..cb74bc1 100644 --- a/AccessQueueService/Data/IAccessQueueRepo.cs +++ b/AccessQueueService/Data/IAccessQueueRepo.cs @@ -16,6 +16,6 @@ namespace AccessQueueService.Data public void Enqueue(AccessTicket ticket); public int DeleteExpiredTickets(); public bool RemoveUser(string userId); - public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit); + public bool DidDequeueUntilFull(AccessQueueConfig config); } } diff --git a/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs b/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs index 1cf3b1b..184b512 100644 --- a/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs +++ b/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs @@ -15,6 +15,9 @@ namespace AccessQueueService.Data internal ulong _nowServing = 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 GetActiveTicketsCount(DateTime activeCutoff) => _accessTickets .Count(t => t.Value.ExpiresOn > DateTime.UtcNow && t.Value.LastActive > activeCutoff); @@ -58,39 +61,53 @@ namespace AccessQueueService.Data return count; } - public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit) + public bool DidDequeueUntilFull(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) + var activeCutoff = now.AddSeconds(-config.ActivitySeconds.Value); + if (config.CacheMilliseconds.HasValue && _cachedActiveUsers.HasValue && (now - _cachedActiveUsersTime).TotalMilliseconds < config.CacheMilliseconds) + { + var numberOfActiveUsers = _cachedActiveUsers.Value; + var openSpots = config.CapacityLimit - numberOfActiveUsers; + if (openSpots <= 0) + { + return true; + } + } + _cachedActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > now && t.Value.LastActive > activeCutoff); + _cachedActiveUsersTime = now; + var openSpotsNew = config.CapacityLimit - _cachedActiveUsers; + if (openSpotsNew <= 0) { return true; } int filledSpots = 0; - while (filledSpots < openSpots && _nowServing < _nextUnusedTicket) + while (filledSpots < openSpotsNew && _nowServing < _nextUnusedTicket) { - if (_accessQueue.TryRemove(_nowServing, out var nextUser)) + if (_accessQueue.TryRemove(_nowServing++, out var nextUser)) { _queueNumbers.TryRemove(nextUser.UserId, out _); if (nextUser.LastActive < activeCutoff) { // User is inactive, throw away their ticket - _nowServing++; continue; } _accessTickets[nextUser.UserId] = new AccessTicket { UserId = nextUser.UserId, - ExpiresOn = now.AddSeconds(expirationSeconds), + ExpiresOn = now.AddSeconds(config.ExpirationSeconds.Value), LastActive = now }; filledSpots++; } - _nowServing++; } - return filledSpots == openSpots; + // Invalidate cache if any users were granted access + if (filledSpots > 0) + { + _cachedActiveUsers = null; + _cachedActiveUsersTime = DateTime.MinValue; + } + return filledSpots == openSpotsNew; } public AccessTicket? GetTicket(string userId) @@ -101,6 +118,8 @@ namespace AccessQueueService.Data public void UpsertTicket(AccessTicket ticket) { _accessTickets[ticket.UserId] = ticket; + _cachedActiveUsers = null; + _cachedActiveUsersTime = DateTime.MinValue; } public bool RemoveUser(string userId) diff --git a/AccessQueueService/Models/AccessQueueConfig.cs b/AccessQueueService/Models/AccessQueueConfig.cs index 413caca..59a7dfe 100644 --- a/AccessQueueService/Models/AccessQueueConfig.cs +++ b/AccessQueueService/Models/AccessQueueConfig.cs @@ -6,6 +6,7 @@ namespace AccessQueueService.Models public int? ActivitySeconds { get; set; } public int? ExpirationSeconds { get; set; } public bool? RollingExpiration { get; set; } + public int? CacheMilliseconds { get; set; } public AccessQueueConfig Clone() { diff --git a/AccessQueueService/Services/AccessService.cs b/AccessQueueService/Services/AccessService.cs index 12f4779..d28410e 100644 --- a/AccessQueueService/Services/AccessService.cs +++ b/AccessQueueService/Services/AccessService.cs @@ -23,7 +23,8 @@ namespace AccessQueueService.Services ExpirationSeconds = _configuration.GetValue("AccessQueue:ExpirationSeconds"), ActivitySeconds = _configuration.GetValue("AccessQueue:ActivitySeconds"), CapacityLimit = _configuration.GetValue("AccessQueue:CapacityLimit"), - RollingExpiration = _configuration.GetValue("AccessQueue:RollingExpiration") + RollingExpiration = _configuration.GetValue("AccessQueue:RollingExpiration"), + CacheMilliseconds = _configuration.GetValue("AccessQueue:CacheMilliseconds") }; } public AccessQueueConfig GetConfiguration() => _config.Clone(); @@ -53,10 +54,7 @@ namespace AccessQueueService.Services await _queueLock.WaitAsync(); try { - var hasCapacity = !_accessQueueRepo.DidDequeueUntilFull( - _config.ActivitySeconds.Value, - _config.ExpirationSeconds.Value, - _config.CapacityLimit.Value); + var hasCapacity = !_accessQueueRepo.DidDequeueUntilFull(_config); var existingTicket = _accessQueueRepo.GetTicket(userId); if (existingTicket != null && existingTicket.ExpiresOn > DateTime.UtcNow) { @@ -109,6 +107,10 @@ namespace AccessQueueService.Services }); _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 { ExpiresOn = null, diff --git a/AccessQueueService/appsettings.json b/AccessQueueService/appsettings.json index 243a619..4969053 100644 --- a/AccessQueueService/appsettings.json +++ b/AccessQueueService/appsettings.json @@ -23,14 +23,15 @@ "Application": "AccessQueueService" } }, - "AccessQueue": { - "CapacityLimit": 100, - "ActivitySeconds": 900, - "ExpirationSeconds": 43200, - "RollingExpiration": true, - "CleanupIntervalSeconds": 60, - "BackupFilePath": "Logs\\backup.json", - "BackupIntervalSeconds": 5 - }, + "AccessQueue": { + "CapacityLimit": 100, + "ActivitySeconds": 900, + "ExpirationSeconds": 43200, + "RollingExpiration": true, + "CleanupIntervalSeconds": 60, + "BackupFilePath": "Logs\\backup.json", + "BackupIntervalSeconds": 5, + "CacheMilliseconds": 1000 + }, "AllowedHosts": "*" } diff --git a/AccessQueueServiceTests/AccessQueueRepoTests.cs b/AccessQueueServiceTests/AccessQueueRepoTests.cs index 3f56f1a..97830f5 100644 --- a/AccessQueueServiceTests/AccessQueueRepoTests.cs +++ b/AccessQueueServiceTests/AccessQueueRepoTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Threading.Tasks; @@ -14,12 +15,43 @@ namespace AccessQueueServiceTests public class AccessQueueRepoTests { 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() { _repo = new TakeANumberAccessQueueRepo(); } + private List 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] public void GetUnexpiredTicketsCount_ReturnsCorrectCount() { @@ -91,7 +123,7 @@ namespace AccessQueueServiceTests var ticket = new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow }; _repo.Enqueue(ticket); - bool result = _repo.DidDequeueUntilFull(60, 60, 1); + bool result = _repo.DidDequeueUntilFull(_simpleConfig); Assert.True(result); Assert.NotNull(_repo.GetTicket("a")); } @@ -101,7 +133,7 @@ namespace AccessQueueServiceTests { _repo.UpsertTicket(new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow }); - bool result = _repo.DidDequeueUntilFull(60, 60, 0); + bool result = _repo.DidDequeueUntilFull(SimpleConfigWithCapacity(0)); Assert.True(result); } @@ -155,7 +187,7 @@ namespace AccessQueueServiceTests var active = new AccessTicket { UserId = "active", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow }; _repo.Enqueue(inactive); _repo.Enqueue(active); - bool result = _repo.DidDequeueUntilFull(5 * 60, 60, 1); + bool result = _repo.DidDequeueUntilFull(_simpleConfig); Assert.True(result); Assert.Null(_repo.GetTicket("inactive")); Assert.NotNull(_repo.GetTicket("active")); @@ -164,12 +196,10 @@ namespace AccessQueueServiceTests [Fact] public void Enqueue_QueuesUsersInOrder() { - var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow }; - var ticket2 = new AccessTicket { UserId = "second", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow }; - var ticket3 = new AccessTicket { UserId = "third", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow }; - _repo.Enqueue(ticket1); - _repo.Enqueue(ticket2); - _repo.Enqueue(ticket3); + foreach (var ticket in GetSimpleTicketList()) + { + _repo.Enqueue(ticket); + } Assert.Equal(0, _repo.GetRequestsAhead("first")); Assert.Equal(1, _repo.GetRequestsAhead("second")); Assert.Equal(2, _repo.GetRequestsAhead("third")); @@ -178,14 +208,12 @@ namespace AccessQueueServiceTests [Fact] public void DidDequeueUntilFull_DequeuesUsersInOrder() { - 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.Enqueue(ticket1); - _repo.Enqueue(ticket2); - _repo.Enqueue(ticket3); + foreach (var ticket in GetSimpleTicketList()) + { + _repo.Enqueue(ticket); + } - bool result = _repo.DidDequeueUntilFull(60 * 60, 60, 1); + bool result = _repo.DidDequeueUntilFull(_simpleConfig); Assert.True(result); Assert.NotNull(_repo.GetTicket("first")); @@ -193,17 +221,33 @@ namespace AccessQueueServiceTests 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] public void Optimize_MaintainsQueueOrder() { - 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.Enqueue(ticket1); - _repo.Enqueue(ticket2); - _repo.Enqueue(ticket3); + foreach (var ticket in GetSimpleTicketList()) + { + _repo.Enqueue(ticket); + } - _repo.DidDequeueUntilFull(60 * 60, 60, 1); + _repo.DidDequeueUntilFull(_simpleConfig); _repo.Optimize(); Assert.NotNull(_repo.GetTicket("first")); @@ -211,7 +255,7 @@ namespace AccessQueueServiceTests Assert.Equal(1, _repo.GetRequestsAhead("third")); Assert.Equal(0ul, _repo._nowServing); - _repo.DidDequeueUntilFull(60 * 60, 60, 2); + _repo.DidDequeueUntilFull(SimpleConfigWithCapacity(2)); Assert.NotNull(_repo.GetTicket("second")); Assert.Equal(0, _repo.GetRequestsAhead("third")); } @@ -219,16 +263,13 @@ namespace AccessQueueServiceTests [Fact] 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._nextUnusedTicket = long.MaxValue - 1; - _repo.Enqueue(ticket1); - _repo.Enqueue(ticket2); - _repo.Enqueue(ticket3); + foreach (var ticket in GetSimpleTicketList()) + { + _repo.Enqueue(ticket); + } Assert.Equal(0ul, _repo._nowServing); Assert.Equal(3ul, _repo._nextUnusedTicket); diff --git a/AccessQueueServiceTests/AccessServiceTests.cs b/AccessQueueServiceTests/AccessServiceTests.cs index f359266..b277ba5 100644 --- a/AccessQueueServiceTests/AccessServiceTests.cs +++ b/AccessQueueServiceTests/AccessServiceTests.cs @@ -16,6 +16,7 @@ namespace AccessQueueServiceTests const int ACT_MILLIS = 1000 * ACT_SECONDS; const int CAP_LIMIT = 5; const int BULK_COUNT = 10000; + const int CACHE_MILLIS = 1000; private readonly AccessService _accessService; public AccessServiceTests() @@ -25,7 +26,8 @@ namespace AccessQueueServiceTests { "AccessQueue:ExpirationSeconds", $"{EXP_SECONDS}" }, { "AccessQueue:ActivitySeconds", $"{ACT_SECONDS}" }, { "AccessQueue:CapacityLimit", $"{CAP_LIMIT}" }, - { "AccessQueue:RollingExpiration", "true" } + { "AccessQueue:RollingExpiration", "true" }, + { "AccessQueue:CacheMilliseconds", $"{CACHE_MILLIS}" } }; var configuration = new ConfigurationBuilder() @@ -87,9 +89,9 @@ namespace AccessQueueServiceTests Assert.NotNull(response); Assert.Null(response.ExpiresOn); Assert.True(response.RequestsAhead == CAP_LIMIT); - Assert.Equal(5, _accessService.UnexpiredTicketsCount); - Assert.Equal(5, _accessService.ActiveTicketsCount); - Assert.Equal(6, _accessService.QueueCount); + Assert.Equal(CAP_LIMIT, _accessService.UnexpiredTicketsCount); + Assert.Equal(CAP_LIMIT, _accessService.ActiveTicketsCount); + Assert.Equal(CAP_LIMIT + 1, _accessService.QueueCount); }