From 493820c3ad1416368ed1e99c5b2c5ad2fdea48e2 Mon Sep 17 00:00:00 2001 From: henry Date: Fri, 4 Jul 2025 02:57:49 -0400 Subject: [PATCH] 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": "*" }