Merge pull request '#28 - Periodically backup service state and restore state if serive is restarted' (#30) from restore-state into main
Reviewed-on: #30
This commit is contained in:
commit
192dd67f52
|
@ -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();
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
using AccessQueueService.Models;
|
||||
using System.Collections.Concurrent;
|
||||
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, ulong> _queueNumbers = [];
|
||||
private Dictionary<ulong, AccessTicket> _accessQueue = [];
|
||||
private ConcurrentDictionary<string, AccessTicket> _accessTickets = new();
|
||||
private ConcurrentDictionary<string, ulong> _queueNumbers = new();
|
||||
private ConcurrentDictionary<ulong, AccessTicket> _accessQueue = new();
|
||||
|
||||
internal ulong _nowServing = 0;
|
||||
internal ulong _nextUnusedTicket = 0;
|
||||
|
@ -45,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;
|
||||
}
|
||||
|
@ -68,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
|
||||
|
@ -102,29 +105,75 @@ 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<ulong, AccessTicket>();
|
||||
var newQueueNumbers = new Dictionary<string, ulong>();
|
||||
var newQueue = new ConcurrentDictionary<ulong, AccessTicket>();
|
||||
var newQueueNumbers = new ConcurrentDictionary<string, ulong>();
|
||||
ulong newIndex = 0;
|
||||
for (ulong i = _nowServing; i < _nextUnusedTicket; i++)
|
||||
{
|
||||
var user = _accessQueue[i];
|
||||
if (_accessQueue.TryGetValue(i, out var user))
|
||||
{
|
||||
newQueue[newIndex] = user;
|
||||
newQueueNumbers[user.UserId] = newIndex++;
|
||||
}
|
||||
}
|
||||
_accessQueue = newQueue;
|
||||
_queueNumbers = newQueueNumbers;
|
||||
_nowServing = 0;
|
||||
_nextUnusedTicket = newIndex;
|
||||
}
|
||||
|
||||
public string ToState()
|
||||
{
|
||||
var state = new TakeANumberAccessQueueRepoState
|
||||
{
|
||||
AccessTickets = new Dictionary<string, AccessTicket>(_accessTickets),
|
||||
AccessQueue = new Dictionary<ulong, AccessTicket>(_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 = new ConcurrentDictionary<string, AccessTicket>(state.AccessTickets);
|
||||
var _accessQueue = new ConcurrentDictionary<ulong, AccessTicket>(state.AccessQueue);
|
||||
var _nextUnusedTicket = 0ul;
|
||||
var _nowServing = ulong.MaxValue;
|
||||
var _queueNumbers = new ConcurrentDictionary<string, ulong>();
|
||||
foreach (var queueItem in state.AccessQueue)
|
||||
{
|
||||
_queueNumbers[queueItem.Value.UserId] = queueItem.Key;
|
||||
_nextUnusedTicket = Math.Max(_nextUnusedTicket, queueItem.Key + 1);
|
||||
_nowServing = Math.Min(_nowServing, queueItem.Key);
|
||||
}
|
||||
|
||||
if (_nowServing == ulong.MaxValue)
|
||||
{
|
||||
_nowServing = 0;
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
_accessQueue = _accessQueue,
|
||||
_accessTickets = _accessTickets,
|
||||
_nextUnusedTicket = _nextUnusedTicket,
|
||||
_nowServing = _nowServing,
|
||||
_queueNumbers = _queueNumbers
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
namespace AccessQueueService.Models
|
||||
{
|
||||
public class TakeANumberAccessQueueRepoState
|
||||
{
|
||||
public Dictionary<string, AccessTicket> AccessTickets { get; set; } = [];
|
||||
public Dictionary<ulong, AccessTicket> AccessQueue { get; set; } = [];
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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 while trying to write state in background backup service.");
|
||||
}
|
||||
await Task.Delay(backupIntervalSeconds, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -28,7 +28,9 @@
|
|||
"ActivitySeconds": 900,
|
||||
"ExpirationSeconds": 43200,
|
||||
"RollingExpiration": true,
|
||||
"CleanupIntervalSeconds": 60
|
||||
"CleanupIntervalSeconds": 60,
|
||||
"BackupFilePath": "Logs\\backup.json",
|
||||
"BackupIntervalSeconds": 5
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
|
|
@ -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<TakeANumberAccessQueueRepoState>(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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
24
README.md
24
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:
|
||||
|
|
Loading…
Reference in New Issue