#28 - Periodically backup service state and restore state if serive is restarted #30

Merged
henry merged 5 commits from restore-state into main 2025-07-04 16:08:14 +00:00
6 changed files with 138 additions and 18 deletions
Showing only changes of commit 493820c3ad - Show all commits

View File

@ -1,10 +1,12 @@
using AccessQueueService.Models; using System.Runtime.Serialization;
using AccessQueueService.Models;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace AccessQueueService.Data namespace AccessQueueService.Data
{ {
public interface IAccessQueueRepo public interface IAccessQueueRepo
{ {
public string ToState();
public int GetUnexpiredTicketsCount(); public int GetUnexpiredTicketsCount();
public int GetActiveTicketsCount(DateTime activeCutoff); public int GetActiveTicketsCount(DateTime activeCutoff);
public int GetQueueCount(); public int GetQueueCount();

View File

@ -1,16 +1,18 @@
using AccessQueueService.Models; using System.Runtime.Serialization;
using System.Text.Json;
using AccessQueueService.Models;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace AccessQueueService.Data namespace AccessQueueService.Data
{ {
public class TakeANumberAccessQueueRepo : IAccessQueueRepo public class TakeANumberAccessQueueRepo : IAccessQueueRepo
{ {
private readonly Dictionary<string, AccessTicket> _accessTickets = []; private Dictionary<string, AccessTicket> _accessTickets = [];
private Dictionary<string, ulong> _queueNumbers = []; private Dictionary<string, ulong> _queueNumbers = [];
private Dictionary<ulong, AccessTicket> _accessQueue = []; private Dictionary<ulong, AccessTicket> _accessQueue = [];
internal ulong _nowServing = 0; 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 GetUnexpiredTicketsCount() => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow);
public int GetActiveTicketsCount(DateTime activeCutoff) => _accessTickets public int GetActiveTicketsCount(DateTime activeCutoff) => _accessTickets
@ -18,21 +20,21 @@ namespace AccessQueueService.Data
public int GetQueueCount() => (int)(_nextUnusedTicket - _nowServing); public int GetQueueCount() => (int)(_nextUnusedTicket - _nowServing);
public int GetRequestsAhead(string userId) 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; ticket.LastActive = DateTime.UtcNow;
return queueNumber >= _nowServing ? (int)(queueNumber - _nowServing) : -1; return queueNumber >= _nowServing ? (int)(queueNumber - _nowServing) : -1;
} }
} }
return -1; return -1;
} }
public void Enqueue(AccessTicket ticket) public void Enqueue(AccessTicket ticket)
{ {
if(_nextUnusedTicket >= long.MaxValue) if (_nextUnusedTicket >= long.MaxValue)
{ {
// Prevent overflow // Prevent overflow
Optimize(); Optimize();
@ -61,7 +63,7 @@ namespace AccessQueueService.Data
var activeCutoff = now.AddSeconds(-activeSeconds); var activeCutoff = now.AddSeconds(-activeSeconds);
var numberOfActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > now && t.Value.LastActive > activeCutoff); var numberOfActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > now && t.Value.LastActive > activeCutoff);
var openSpots = capacityLimit - numberOfActiveUsers; var openSpots = capacityLimit - numberOfActiveUsers;
if(openSpots <= 0) if (openSpots <= 0)
{ {
return true; return true;
} }
@ -102,7 +104,7 @@ namespace AccessQueueService.Data
public bool RemoveUser(string userId) public bool RemoveUser(string userId)
{ {
if(_queueNumbers.TryGetValue(userId, out var queueNumber)) if (_queueNumbers.TryGetValue(userId, out var queueNumber))
{ {
_accessQueue.Remove(queueNumber); _accessQueue.Remove(queueNumber);
_queueNumbers.Remove(userId); _queueNumbers.Remove(userId);
@ -126,5 +128,51 @@ namespace AccessQueueService.Data
_nowServing = 0; _nowServing = 0;
_nextUnusedTicket = newIndex; _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<TakeANumberAccessQueueRepoState?>(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<string, ulong> _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
};
}
} }
} }

View File

@ -0,0 +1,8 @@
namespace AccessQueueService.Models
{
public class TakeANumberAccessQueueRepoState
{
public Dictionary<string, AccessTicket> AccessTickets { get; set; } = [];
public Dictionary<ulong, AccessTicket> AccessQueue { get; set; } = [];
}
}

View File

@ -17,8 +17,25 @@ builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddSingleton<IAccessService, AccessService>(); builder.Services.AddSingleton<IAccessService, AccessService>();
builder.Services.AddSingleton<IAccessQueueRepo, TakeANumberAccessQueueRepo>(); builder.Services.AddSingleton<IAccessQueueRepo>(sp =>
{
string? filePath = builder.Configuration.GetValue<string>("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<AccessCleanupBackgroundService>(); builder.Services.AddHostedService<AccessCleanupBackgroundService>();
builder.Services.AddHostedService<AccessQueueSerializerService>();
var app = builder.Build(); var app = builder.Build();

View File

@ -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<AccessQueueSerializerService> _logger;
public AccessQueueSerializerService(IAccessQueueRepo accessRepo, IConfiguration config, ILogger<AccessQueueSerializerService> logger)
{
_accessRepo = accessRepo;
_config = config;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var backupIntervalSeconds = _config.GetValue<int>("AccessQueue:BackupIntervalSeconds") * 1000;
var backupPath = _config.GetValue<string>("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);
}
}
}
}

View File

@ -23,12 +23,14 @@
"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",
"BackupIntervalSeconds": 5
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }