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 _logger; private readonly object _walLock = new(); public Persistence(ServerConfig config, ILogger 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 ReadWal(string lobbyId, long fromEventId = 0) { var lobbyDir = GetLobbyDir(lobbyId); var events = new List(); 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(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(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); } }