#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;
namespace AccessQueueService.Data
{
public interface IAccessQueueRepo
{
public string ToState();
public int GetUnexpiredTicketsCount();
public int GetActiveTicketsCount(DateTime activeCutoff);
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;
namespace AccessQueueService.Data
{
public class TakeANumberAccessQueueRepo : IAccessQueueRepo
{
private readonly Dictionary<string, AccessTicket> _accessTickets = [];
private Dictionary<string, AccessTicket> _accessTickets = [];
private Dictionary<string, ulong> _queueNumbers = [];
private Dictionary<ulong, AccessTicket> _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<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.AddSwaggerGen();
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<AccessQueueSerializerService>();
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"
}
},
"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": "*"
}