add caching for performance

This commit is contained in:
henry 2025-07-05 20:06:10 -04:00
parent a4ab44faf9
commit f3f599c5df
7 changed files with 127 additions and 61 deletions

View File

@ -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(int activeSeconds, int expirationSeconds, int capacityLimit); public bool DidDequeueUntilFull(AccessQueueConfig config);
} }
} }

View File

@ -15,6 +15,9 @@ 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);
@ -58,39 +61,53 @@ namespace AccessQueueService.Data
return count; return count;
} }
public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit) public bool DidDequeueUntilFull(AccessQueueConfig config)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var activeCutoff = now.AddSeconds(-activeSeconds); var activeCutoff = now.AddSeconds(-config.ActivitySeconds.Value);
var numberOfActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > now && t.Value.LastActive > activeCutoff); if (config.CacheMilliseconds.HasValue && _cachedActiveUsers.HasValue && (now - _cachedActiveUsersTime).TotalMilliseconds < config.CacheMilliseconds)
var openSpots = capacityLimit - numberOfActiveUsers; {
if (openSpots <= 0) 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; return true;
} }
int filledSpots = 0; 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 _); _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(expirationSeconds), ExpiresOn = now.AddSeconds(config.ExpirationSeconds.Value),
LastActive = now LastActive = now
}; };
filledSpots++; 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) public AccessTicket? GetTicket(string userId)
@ -101,6 +118,8 @@ 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)

View File

@ -6,6 +6,7 @@ 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

@ -23,7 +23,8 @@ 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();
@ -53,10 +54,7 @@ namespace AccessQueueService.Services
await _queueLock.WaitAsync(); await _queueLock.WaitAsync();
try try
{ {
var hasCapacity = !_accessQueueRepo.DidDequeueUntilFull( var hasCapacity = !_accessQueueRepo.DidDequeueUntilFull(_config);
_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)
{ {
@ -109,6 +107,10 @@ 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

@ -23,14 +23,15 @@
"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": "*"
} }

View File

@ -1,6 +1,7 @@
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,12 +15,43 @@ 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()
{ {
@ -91,7 +123,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(60, 60, 1); bool result = _repo.DidDequeueUntilFull(_simpleConfig);
Assert.True(result); Assert.True(result);
Assert.NotNull(_repo.GetTicket("a")); 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 }); _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); Assert.True(result);
} }
@ -155,7 +187,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(5 * 60, 60, 1); bool result = _repo.DidDequeueUntilFull(_simpleConfig);
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"));
@ -164,12 +196,10 @@ namespace AccessQueueServiceTests
[Fact] [Fact]
public void Enqueue_QueuesUsersInOrder() public void Enqueue_QueuesUsersInOrder()
{ {
var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow }; foreach (var ticket in GetSimpleTicketList())
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(ticket);
_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"));
@ -178,14 +208,12 @@ namespace AccessQueueServiceTests
[Fact] [Fact]
public void DidDequeueUntilFull_DequeuesUsersInOrder() public void DidDequeueUntilFull_DequeuesUsersInOrder()
{ {
var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow }; foreach (var ticket in GetSimpleTicketList())
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(ticket);
_repo.Enqueue(ticket1); }
_repo.Enqueue(ticket2);
_repo.Enqueue(ticket3);
bool result = _repo.DidDequeueUntilFull(60 * 60, 60, 1); bool result = _repo.DidDequeueUntilFull(_simpleConfig);
Assert.True(result); Assert.True(result);
Assert.NotNull(_repo.GetTicket("first")); Assert.NotNull(_repo.GetTicket("first"));
@ -193,17 +221,33 @@ 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()
{ {
var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow }; foreach (var ticket in GetSimpleTicketList())
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(ticket);
_repo.Enqueue(ticket1); }
_repo.Enqueue(ticket2);
_repo.Enqueue(ticket3);
_repo.DidDequeueUntilFull(60 * 60, 60, 1); _repo.DidDequeueUntilFull(_simpleConfig);
_repo.Optimize(); _repo.Optimize();
Assert.NotNull(_repo.GetTicket("first")); Assert.NotNull(_repo.GetTicket("first"));
@ -211,7 +255,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(60 * 60, 60, 2); _repo.DidDequeueUntilFull(SimpleConfigWithCapacity(2));
Assert.NotNull(_repo.GetTicket("second")); Assert.NotNull(_repo.GetTicket("second"));
Assert.Equal(0, _repo.GetRequestsAhead("third")); Assert.Equal(0, _repo.GetRequestsAhead("third"));
} }
@ -219,16 +263,13 @@ 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;
_repo.Enqueue(ticket1); foreach (var ticket in GetSimpleTicketList())
_repo.Enqueue(ticket2); {
_repo.Enqueue(ticket3); _repo.Enqueue(ticket);
}
Assert.Equal(0ul, _repo._nowServing); Assert.Equal(0ul, _repo._nowServing);
Assert.Equal(3ul, _repo._nextUnusedTicket); Assert.Equal(3ul, _repo._nextUnusedTicket);

View File

@ -16,6 +16,7 @@ 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()
@ -25,7 +26,8 @@ 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()
@ -87,9 +89,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(5, _accessService.UnexpiredTicketsCount); Assert.Equal(CAP_LIMIT, _accessService.UnexpiredTicketsCount);
Assert.Equal(5, _accessService.ActiveTicketsCount); Assert.Equal(CAP_LIMIT, _accessService.ActiveTicketsCount);
Assert.Equal(6, _accessService.QueueCount); Assert.Equal(CAP_LIMIT + 1, _accessService.QueueCount);
} }