Files
Server/Persistence.cs
2026-04-26 12:44:06 +02:00

246 lines
8.1 KiB
C#

namespace GeoSus.Server;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
// Write-Ahead Log a Snapshoty pro persistenci
public class Persistence
{
private readonly ServerConfig _config;
private readonly ILogger<Persistence> _logger;
private readonly object _walLock = new();
public Persistence(ServerConfig config, ILogger<Persistence> logger)
{
_config = config;
_logger = logger;
// Vytvoříme adresář pro data
Directory.CreateDirectory(_config.DataPath);
}
#region WAL
public void AppendToWal(string lobbyId, GameEvent evt)
{
var lobbyDir = GetLobbyDir(lobbyId);
Directory.CreateDirectory(lobbyDir);
var walPath = GetCurrentWalPath(lobbyDir);
var json = JsonSerializer.Serialize(evt, JsonOptions.Default);
lock (_walLock)
{
// Kontrola velikosti WAL
if (File.Exists(walPath))
{
var fileInfo = new FileInfo(walPath);
if (fileInfo.Length > _config.WalMaxSizeMb * 1024 * 1024)
{
// Rotace - nový soubor
var newWalPath = Path.Combine(lobbyDir, $"wal_{DateTime.UtcNow:yyyyMMddHHmmss}.ndjson");
walPath = newWalPath;
}
}
using var fs = new FileStream(walPath, FileMode.Append, FileAccess.Write, FileShare.Read);
using var writer = new StreamWriter(fs, Encoding.UTF8);
writer.WriteLine(json);
writer.Flush();
fs.Flush(true); // fsync
}
}
public List<GameEvent> ReadWal(string lobbyId, long fromEventId = 0)
{
var lobbyDir = GetLobbyDir(lobbyId);
var events = new List<GameEvent>();
if (!Directory.Exists(lobbyDir))
return events;
// Načteme všechny WAL soubory seřazené podle času
var walFiles = Directory.GetFiles(lobbyDir, "wal_*.ndjson")
.OrderBy(f => f)
.ToList();
foreach (var walPath in walFiles)
{
try
{
var lines = File.ReadAllLines(walPath, Encoding.UTF8);
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line)) continue;
var evt = JsonSerializer.Deserialize<GameEvent>(line, JsonOptions.Default);
if (evt != null && evt.EventId > fromEventId)
{
events.Add(evt);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Chyba při čtení WAL {Path}", walPath);
}
}
return events.OrderBy(e => e.EventId).ToList();
}
private string GetCurrentWalPath(string lobbyDir)
{
// Najdeme nejnovější WAL nebo vytvoříme nový
var walFiles = Directory.GetFiles(lobbyDir, "wal_*.ndjson");
if (walFiles.Length == 0)
{
return Path.Combine(lobbyDir, $"wal_{DateTime.UtcNow:yyyyMMddHHmmss}.ndjson");
}
return walFiles.OrderByDescending(f => f).First();
}
#endregion
#region Snapshots
public void SaveSnapshot(string lobbyId, LobbySnapshot snapshot)
{
var lobbyDir = GetLobbyDir(lobbyId);
Directory.CreateDirectory(lobbyDir);
// Serializujeme bez checksumu
snapshot.Timestamp = DateTime.UtcNow;
var json = JsonSerializer.Serialize(snapshot, JsonOptions.Indented);
// Spočítáme checksum
var checksum = ComputeChecksum(json);
snapshot.Checksum = checksum;
// Serializujeme znovu s checksumem
json = JsonSerializer.Serialize(snapshot, JsonOptions.Indented);
var snapshotPath = Path.Combine(lobbyDir, $"snapshot_{snapshot.LastEventId}.json");
var tempPath = snapshotPath + ".tmp";
// Atomic write - temp + rename
File.WriteAllText(tempPath, json, Encoding.UTF8);
File.Move(tempPath, snapshotPath, overwrite: true);
_logger.LogInformation("Snapshot uložen: {Path}, eventId: {EventId}", snapshotPath, snapshot.LastEventId);
// Čištění starých snapshotů
CleanupOldSnapshots(lobbyDir);
}
public LobbySnapshot? LoadLatestSnapshot(string lobbyId)
{
var lobbyDir = GetLobbyDir(lobbyId);
if (!Directory.Exists(lobbyDir))
return null;
var snapshotFiles = Directory.GetFiles(lobbyDir, "snapshot_*.json")
.OrderByDescending(f => f)
.ToList();
foreach (var snapshotPath in snapshotFiles)
{
try
{
var json = File.ReadAllText(snapshotPath, Encoding.UTF8);
var snapshot = JsonSerializer.Deserialize<LobbySnapshot>(json, JsonOptions.Default);
if (snapshot == null) continue;
// Validace checksumu
var storedChecksum = snapshot.Checksum;
snapshot.Checksum = "";
var jsonWithoutChecksum = JsonSerializer.Serialize(snapshot, JsonOptions.Indented);
var computedChecksum = ComputeChecksum(jsonWithoutChecksum);
if (storedChecksum != computedChecksum)
{
_logger.LogWarning("Checksum mismatch pro snapshot {Path}", snapshotPath);
continue;
}
snapshot.Checksum = storedChecksum;
return snapshot;
}
catch (Exception ex)
{
_logger.LogError(ex, "Chyba při čtení snapshot {Path}", snapshotPath);
}
}
return null;
}
private void CleanupOldSnapshots(string lobbyDir, int keepCount = 5)
{
var snapshotFiles = Directory.GetFiles(lobbyDir, "snapshot_*.json")
.OrderByDescending(f => f)
.Skip(keepCount)
.ToList();
foreach (var oldSnapshot in snapshotFiles)
{
try
{
File.Delete(oldSnapshot);
_logger.LogDebug("Smazán starý snapshot: {Path}", oldSnapshot);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Nelze smazat snapshot {Path}", oldSnapshot);
}
}
}
private static string ComputeChecksum(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
#endregion
#region Archive
public void ArchiveLobby(string lobbyId)
{
var lobbyDir = GetLobbyDir(lobbyId);
if (!Directory.Exists(lobbyDir))
return;
var archiveDir = Path.Combine(_config.DataPath, "archive");
Directory.CreateDirectory(archiveDir);
var archivePath = Path.Combine(archiveDir, $"{lobbyId}_{DateTime.UtcNow:yyyyMMddHHmmss}");
Directory.Move(lobbyDir, archivePath);
_logger.LogInformation("Lobby archivováno: {LobbyId} -> {ArchivePath}", lobbyId, archivePath);
}
public void DeleteLobbyData(string lobbyId)
{
var lobbyDir = GetLobbyDir(lobbyId);
if (Directory.Exists(lobbyDir))
{
Directory.Delete(lobbyDir, recursive: true);
_logger.LogInformation("Data lobby smazána: {LobbyId}", lobbyId);
}
}
#endregion
private string GetLobbyDir(string lobbyId)
{
return Path.Combine(_config.DataPath, "lobbies", lobbyId);
}
}