From 493820c3ad1416368ed1e99c5b2c5ad2fdea48e2 Mon Sep 17 00:00:00 2001 From: henry Date: Fri, 4 Jul 2025 02:57:49 -0400 Subject: [PATCH 1/5] Added automatic backup and restore. TODO: document and test --- AccessQueueService/Data/IAccessQueueRepo.cs | 4 +- .../Data/TakeANumberAccessQueueRepo.cs | 66 ++++++++++++++++--- .../Models/TakeANumberAccessQueueRepoState.cs | 8 +++ AccessQueueService/Program.cs | 19 +++++- .../Services/AccessQueueSerializerService.cs | 43 ++++++++++++ AccessQueueService/appsettings.json | 16 +++-- 6 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 AccessQueueService/Models/TakeANumberAccessQueueRepoState.cs create mode 100644 AccessQueueService/Services/AccessQueueSerializerService.cs diff --git a/AccessQueueService/Data/IAccessQueueRepo.cs b/AccessQueueService/Data/IAccessQueueRepo.cs index f3c0e8b..3f76d64 100644 --- a/AccessQueueService/Data/IAccessQueueRepo.cs +++ b/AccessQueueService/Data/IAccessQueueRepo.cs @@ -1,10 +1,12 @@ -using AccessQueueService.Models; +using System.Runtime.Serialization; +using AccessQueueService.Models; using Microsoft.Extensions.Configuration; namespace AccessQueueService.Data { public interface IAccessQueueRepo { + public string ToState(); public int GetUnexpiredTicketsCount(); public int GetActiveTicketsCount(DateTime activeCutoff); public int GetQueueCount(); diff --git a/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs b/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs index 7289845..f86c22d 100644 --- a/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs +++ b/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs @@ -1,16 +1,18 @@ -using AccessQueueService.Models; +using System.Runtime.Serialization; +using System.Text.Json; +using AccessQueueService.Models; using Microsoft.Extensions.Configuration; namespace AccessQueueService.Data { public class TakeANumberAccessQueueRepo : IAccessQueueRepo { - private readonly Dictionary _accessTickets = []; + private Dictionary _accessTickets = []; private Dictionary _queueNumbers = []; private Dictionary _accessQueue = []; internal ulong _nowServing = 0; - internal ulong _nextUnusedTicket = 0; + internal ulong _nextUnusedTicket = 0; public int GetUnexpiredTicketsCount() => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow); public int GetActiveTicketsCount(DateTime activeCutoff) => _accessTickets @@ -18,21 +20,21 @@ namespace AccessQueueService.Data public int GetQueueCount() => (int)(_nextUnusedTicket - _nowServing); public int GetRequestsAhead(string userId) { - if(_queueNumbers.TryGetValue(userId, out var queueNumber)) + if (_queueNumbers.TryGetValue(userId, out var queueNumber)) { - if(_accessQueue.TryGetValue(queueNumber, out var ticket)) + if (_accessQueue.TryGetValue(queueNumber, out var ticket)) { ticket.LastActive = DateTime.UtcNow; return queueNumber >= _nowServing ? (int)(queueNumber - _nowServing) : -1; } } return -1; - + } public void Enqueue(AccessTicket ticket) { - if(_nextUnusedTicket >= long.MaxValue) + if (_nextUnusedTicket >= long.MaxValue) { // Prevent overflow Optimize(); @@ -61,7 +63,7 @@ namespace AccessQueueService.Data 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) + if (openSpots <= 0) { return true; } @@ -102,7 +104,7 @@ namespace AccessQueueService.Data public bool RemoveUser(string userId) { - if(_queueNumbers.TryGetValue(userId, out var queueNumber)) + if (_queueNumbers.TryGetValue(userId, out var queueNumber)) { _accessQueue.Remove(queueNumber); _queueNumbers.Remove(userId); @@ -126,5 +128,51 @@ namespace AccessQueueService.Data _nowServing = 0; _nextUnusedTicket = newIndex; } + + public string ToState() + { + var state = new TakeANumberAccessQueueRepoState + { + AccessTickets = _accessTickets, + AccessQueue = _accessQueue, + }; + + return JsonSerializer.Serialize(state); + } + + public static TakeANumberAccessQueueRepo FromState(string stateJson) + { + var state = JsonSerializer.Deserialize(stateJson); + if (state?.AccessTickets == null || state?.AccessQueue == null) + { + return new(); + } + + var _accessTickets = state.AccessTickets; + var _accessQueue = state.AccessQueue; + var _nextUnusedTicket = 0ul; + var _nowServing = ulong.MaxValue; + Dictionary _queueNumbers = []; + foreach (var queueItem in state.AccessQueue) + { + _queueNumbers.Add(queueItem.Value.UserId, queueItem.Key); + _nextUnusedTicket = Math.Max(_nextUnusedTicket, queueItem.Key); + _nowServing = Math.Min(_nowServing, queueItem.Key); + } + + if (_nowServing == ulong.MaxValue) + { + _nowServing = 0; + } + + return new() + { + _accessQueue = _accessQueue, + _accessTickets = _accessTickets, + _nextUnusedTicket = _nextUnusedTicket, + _nowServing = _nowServing, + _queueNumbers = _queueNumbers + }; + } } } diff --git a/AccessQueueService/Models/TakeANumberAccessQueueRepoState.cs b/AccessQueueService/Models/TakeANumberAccessQueueRepoState.cs new file mode 100644 index 0000000..93f328a --- /dev/null +++ b/AccessQueueService/Models/TakeANumberAccessQueueRepoState.cs @@ -0,0 +1,8 @@ +namespace AccessQueueService.Models +{ + public class TakeANumberAccessQueueRepoState + { + public Dictionary AccessTickets { get; set; } = []; + public Dictionary AccessQueue { get; set; } = []; + } +} diff --git a/AccessQueueService/Program.cs b/AccessQueueService/Program.cs index 3e6dbd5..9dd670d 100644 --- a/AccessQueueService/Program.cs +++ b/AccessQueueService/Program.cs @@ -17,8 +17,25 @@ builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => +{ + string? filePath = builder.Configuration.GetValue("AccessQueue:BackupFilePath"); + if (!string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath)) + { + try + { + var json = File.ReadAllText(filePath); + return TakeANumberAccessQueueRepo.FromState(json); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to load state from {filePath}. Error message: {ex.Message}"); + } + } + return new TakeANumberAccessQueueRepo(); +}); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); var app = builder.Build(); diff --git a/AccessQueueService/Services/AccessQueueSerializerService.cs b/AccessQueueService/Services/AccessQueueSerializerService.cs new file mode 100644 index 0000000..0797d22 --- /dev/null +++ b/AccessQueueService/Services/AccessQueueSerializerService.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using AccessQueueService.Data; + +namespace AccessQueueService.Services +{ + public class AccessQueueSerializerService : BackgroundService + { + private readonly IAccessQueueRepo _accessRepo; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public AccessQueueSerializerService(IAccessQueueRepo accessRepo, IConfiguration config, ILogger logger) + { + _accessRepo = accessRepo; + _config = config; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var backupIntervalSeconds = _config.GetValue("AccessQueue:BackupIntervalSeconds") * 1000; + var backupPath = _config.GetValue("AccessQueue:BackupFilePath"); + if (backupIntervalSeconds == 0 || string.IsNullOrEmpty(backupPath)) + { + return; + } + while (!stoppingToken.IsCancellationRequested) + { + try + { + _logger.LogInformation($"Writing backup to {backupPath}"); + var stateJson = _accessRepo.ToState(); + File.WriteAllText(backupPath, stateJson); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception occurred during background cleanup."); + } + await Task.Delay(backupIntervalSeconds, stoppingToken); + } + } + } +} diff --git a/AccessQueueService/appsettings.json b/AccessQueueService/appsettings.json index 42f1ca9..243a619 100644 --- a/AccessQueueService/appsettings.json +++ b/AccessQueueService/appsettings.json @@ -23,12 +23,14 @@ "Application": "AccessQueueService" } }, - "AccessQueue": { - "CapacityLimit": 100, - "ActivitySeconds": 900, - "ExpirationSeconds": 43200, - "RollingExpiration": true, - "CleanupIntervalSeconds": 60 - }, + "AccessQueue": { + "CapacityLimit": 100, + "ActivitySeconds": 900, + "ExpirationSeconds": 43200, + "RollingExpiration": true, + "CleanupIntervalSeconds": 60, + "BackupFilePath": "Logs\\backup.json", + "BackupIntervalSeconds": 5 + }, "AllowedHosts": "*" } -- 2.40.1 From 18d39b5dc2d8faa2b3cdd11659629b2947a28fd9 Mon Sep 17 00:00:00 2001 From: henry Date: Fri, 4 Jul 2025 11:34:46 -0400 Subject: [PATCH 2/5] Fix small bug and add tests for serializing and deserializing --- .../Data/TakeANumberAccessQueueRepo.cs | 2 +- .../AccessQueueRepoTests.cs | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs b/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs index f86c22d..ad45a83 100644 --- a/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs +++ b/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs @@ -156,7 +156,7 @@ namespace AccessQueueService.Data foreach (var queueItem in state.AccessQueue) { _queueNumbers.Add(queueItem.Value.UserId, queueItem.Key); - _nextUnusedTicket = Math.Max(_nextUnusedTicket, queueItem.Key); + _nextUnusedTicket = Math.Max(_nextUnusedTicket, queueItem.Key + 1); _nowServing = Math.Min(_nowServing, queueItem.Key); } diff --git a/AccessQueueServiceTests/AccessQueueRepoTests.cs b/AccessQueueServiceTests/AccessQueueRepoTests.cs index f80a9a3..3f56f1a 100644 --- a/AccessQueueServiceTests/AccessQueueRepoTests.cs +++ b/AccessQueueServiceTests/AccessQueueRepoTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Text.Json; using System.Threading.Tasks; using AccessQueueService.Data; using AccessQueueService.Models; @@ -235,5 +236,50 @@ namespace AccessQueueServiceTests Assert.Equal(0, _repo.GetRequestsAhead("first")); Assert.Equal(2, _repo.GetRequestsAhead("third")); } + + [Fact] + public void ToState_ReturnsAccurateJson() + { + var ticketWithAccess = new AccessTicket { UserId = "access", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow }; + var ticketWithoutAccess = new AccessTicket { UserId = "noAccess", LastActive = DateTime.UtcNow }; + + _repo.UpsertTicket(ticketWithAccess); + _repo.Enqueue(ticketWithoutAccess); + + string stateJson = _repo.ToState(); + var state = JsonSerializer.Deserialize(stateJson); + + Assert.NotNull(state?.AccessQueue); + Assert.NotNull(state?.AccessTickets); + Assert.Single(state!.AccessTickets); + Assert.Single(state!.AccessQueue); + + Assert.Equal(ticketWithAccess.UserId, state.AccessTickets.First().Key); + Assert.Equal(ticketWithAccess.ExpiresOn, state.AccessTickets.First().Value.ExpiresOn); + Assert.Equal(ticketWithAccess.LastActive, state.AccessTickets.First().Value.LastActive); + + Assert.Equal(ticketWithoutAccess.UserId, state.AccessQueue.First().Value.UserId); + Assert.Equal(ticketWithoutAccess.LastActive, state.AccessQueue.First().Value.LastActive); + } + + [Fact] + public void FromState_DeserializesJsonCorrectly() + { + var ticketWithAccess = new AccessTicket { UserId = "access", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow }; + var ticketWithoutAccess = new AccessTicket { UserId = "noAccess", LastActive = DateTime.UtcNow }; + + _repo.UpsertTicket(ticketWithAccess); + _repo.Enqueue(ticketWithoutAccess); + + string stateJson = _repo.ToState(); + var deserializedRepo = TakeANumberAccessQueueRepo.FromState(stateJson); + + Assert.Equal(1, deserializedRepo.GetUnexpiredTicketsCount()); + Assert.Equal(1, deserializedRepo.GetQueueCount()); + + Assert.Equal(deserializedRepo.GetTicket("access")!.ExpiresOn, ticketWithAccess.ExpiresOn); + Assert.Null(deserializedRepo.GetTicket("noAccess")); + Assert.Equal(0, deserializedRepo.GetRequestsAhead("noAccess")); + } } } -- 2.40.1 From e7aad222959e581371ddabcbaa286f742d7eca92 Mon Sep 17 00:00:00 2001 From: henry Date: Fri, 4 Jul 2025 11:45:15 -0400 Subject: [PATCH 3/5] Add backup and restore functionality to README documentation --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index fc9fb30..c6b2e57 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ Configuration is set in `appsettings.json` or via environment variables. The mai - **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). +- **BackupFilePath**: The file path where the access queue state will be periodically saved (no default; optional). +- **BackupIntervalSeconds**: How often (in seconds) the state is backed up to disk (no default; optional). Example `appsettings.json`: ```json @@ -97,6 +99,28 @@ Example `appsettings.json`: } ``` +## State Persistence and Backup + +AccessQueueService automatically saves its in-memory state (active users and queue) to disk at regular intervals and restores it on startup. This helps prevent data loss in case of unexpected shutdowns or restarts. + +- **Backup Location:** The backup file path is set via the `AccessQueue:BackupFilePath` configuration variable. If this is not set, no backup will be performed. +- **Backup Interval:** The frequency of backups is controlled by `AccessQueue:BackupIntervalSeconds` (in seconds). If this is not set or is zero, backups are disabled. +- **Startup Restore:** On startup, if a backup file exists at the specified path, the service will attempt to restore the previous state from this file. If the file is missing or corrupted, the service starts with an empty queue and access list. +- **Failure Handling:** Any errors during backup or restore are logged, but do not prevent the service from running. +- **Backup Format:** The backup is saved as a JSON file containing the current state of the access queue and active users. + +Example configuration: +```json +{ + "AccessQueue": { + "BackupFilePath": "Logs/backup.json", + "BackupIntervalSeconds": 60 + } +} +``` + +> **Note:** Ensure the backup file path is writable by the service. Regular backups help prevent data loss in case of unexpected shutdowns. + ## AccessResponse Object The `AccessResponse` object returned by the API contains the following properties: -- 2.40.1 From e8a7cc588ff01be0d9976a803c9e87e5b512ff58 Mon Sep 17 00:00:00 2001 From: henry Date: Fri, 4 Jul 2025 11:50:38 -0400 Subject: [PATCH 4/5] Fix copy-paste error message typo --- AccessQueueService/Services/AccessQueueSerializerService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AccessQueueService/Services/AccessQueueSerializerService.cs b/AccessQueueService/Services/AccessQueueSerializerService.cs index 0797d22..c308330 100644 --- a/AccessQueueService/Services/AccessQueueSerializerService.cs +++ b/AccessQueueService/Services/AccessQueueSerializerService.cs @@ -34,7 +34,7 @@ namespace AccessQueueService.Services } catch (Exception ex) { - _logger.LogError(ex, "Exception occurred during background cleanup."); + _logger.LogError(ex, "Exception occurred while trying to write state in background backup service."); } await Task.Delay(backupIntervalSeconds, stoppingToken); } -- 2.40.1 From f958ba5ddd9a2ddf7c805ffe47525e4e1e93f884 Mon Sep 17 00:00:00 2001 From: henry Date: Fri, 4 Jul 2025 12:07:22 -0400 Subject: [PATCH 5/5] Modify repo to use concurrent dictionaries for thread safety during backup --- .../Data/TakeANumberAccessQueueRepo.cs | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs b/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs index ad45a83..1cf3b1b 100644 --- a/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs +++ b/AccessQueueService/Data/TakeANumberAccessQueueRepo.cs @@ -1,4 +1,5 @@ -using System.Runtime.Serialization; +using System.Collections.Concurrent; +using System.Runtime.Serialization; using System.Text.Json; using AccessQueueService.Models; using Microsoft.Extensions.Configuration; @@ -7,9 +8,9 @@ namespace AccessQueueService.Data { public class TakeANumberAccessQueueRepo : IAccessQueueRepo { - private Dictionary _accessTickets = []; - private Dictionary _queueNumbers = []; - private Dictionary _accessQueue = []; + private ConcurrentDictionary _accessTickets = new(); + private ConcurrentDictionary _queueNumbers = new(); + private ConcurrentDictionary _accessQueue = new(); internal ulong _nowServing = 0; internal ulong _nextUnusedTicket = 0; @@ -47,12 +48,12 @@ namespace AccessQueueService.Data public int DeleteExpiredTickets() { var cutoff = DateTime.UtcNow; - var expiredTickets = _accessTickets.Where(t => t.Value.ExpiresOn < cutoff); + var expiredTickets = _accessTickets.Where(t => t.Value.ExpiresOn < cutoff).ToList(); int count = 0; foreach (var ticket in expiredTickets) { count++; - _accessTickets.Remove(ticket.Key); + _accessTickets.TryRemove(ticket.Key, out _); } return count; } @@ -70,13 +71,13 @@ namespace AccessQueueService.Data int filledSpots = 0; while (filledSpots < openSpots && _nowServing < _nextUnusedTicket) { - if (_accessQueue.TryGetValue(_nowServing, out var nextUser)) + if (_accessQueue.TryRemove(_nowServing, out var nextUser)) { - _accessQueue.Remove(_nowServing); - _queueNumbers.Remove(nextUser.UserId); + _queueNumbers.TryRemove(nextUser.UserId, out _); if (nextUser.LastActive < activeCutoff) { // User is inactive, throw away their ticket + _nowServing++; continue; } _accessTickets[nextUser.UserId] = new AccessTicket @@ -104,24 +105,25 @@ namespace AccessQueueService.Data public bool RemoveUser(string userId) { - if (_queueNumbers.TryGetValue(userId, out var queueNumber)) + if (_queueNumbers.TryRemove(userId, out var queueNumber)) { - _accessQueue.Remove(queueNumber); - _queueNumbers.Remove(userId); + _accessQueue.TryRemove(queueNumber, out _); } - return _accessTickets.Remove(userId); + return _accessTickets.TryRemove(userId, out _); } internal void Optimize() { - var newQueue = new Dictionary(); - var newQueueNumbers = new Dictionary(); + var newQueue = new ConcurrentDictionary(); + var newQueueNumbers = new ConcurrentDictionary(); ulong newIndex = 0; for (ulong i = _nowServing; i < _nextUnusedTicket; i++) { - var user = _accessQueue[i]; - newQueue[newIndex] = user; - newQueueNumbers[user.UserId] = newIndex++; + if (_accessQueue.TryGetValue(i, out var user)) + { + newQueue[newIndex] = user; + newQueueNumbers[user.UserId] = newIndex++; + } } _accessQueue = newQueue; _queueNumbers = newQueueNumbers; @@ -133,10 +135,9 @@ namespace AccessQueueService.Data { var state = new TakeANumberAccessQueueRepoState { - AccessTickets = _accessTickets, - AccessQueue = _accessQueue, + AccessTickets = new Dictionary(_accessTickets), + AccessQueue = new Dictionary(_accessQueue), }; - return JsonSerializer.Serialize(state); } @@ -148,14 +149,14 @@ namespace AccessQueueService.Data return new(); } - var _accessTickets = state.AccessTickets; - var _accessQueue = state.AccessQueue; + var _accessTickets = new ConcurrentDictionary(state.AccessTickets); + var _accessQueue = new ConcurrentDictionary(state.AccessQueue); var _nextUnusedTicket = 0ul; var _nowServing = ulong.MaxValue; - Dictionary _queueNumbers = []; + var _queueNumbers = new ConcurrentDictionary(); foreach (var queueItem in state.AccessQueue) { - _queueNumbers.Add(queueItem.Value.UserId, queueItem.Key); + _queueNumbers[queueItem.Value.UserId] = queueItem.Key; _nextUnusedTicket = Math.Max(_nextUnusedTicket, queueItem.Key + 1); _nowServing = Math.Min(_nowServing, queueItem.Key); } -- 2.40.1