using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using MessagePack; namespace GameCult.Networking { /// /// Stores accepted shard-log entries in their replica catch-up wire shape. /// public interface ICultNetShardMutationLogStore { /// /// Reads accepted log entries for a shard after the supplied sequence. /// IReadOnlyList Read(string shardId, long afterSequence = 0, int? limit = null); /// /// Appends or replaces one accepted log entry for a shard. /// void Append(string shardId, CultNetShardLogEntryMessage entry); } /// /// Persists shard-log entries as one MessagePack file per shard. /// public sealed class CultNetFileShardMutationLogStore : ICultNetShardMutationLogStore { private readonly string _rootPath; private readonly object _gate = new(); /// /// Creates a file-backed shard mutation log store. /// public CultNetFileShardMutationLogStore(string rootPath) { if (string.IsNullOrWhiteSpace(rootPath)) throw new ArgumentException("Value must be non-empty.", nameof(rootPath)); _rootPath = rootPath; Directory.CreateDirectory(_rootPath); } /// public IReadOnlyList Read(string shardId, long afterSequence = 0, int? limit = null) { if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId)); if (afterSequence < 0) throw new ArgumentOutOfRangeException(nameof(afterSequence), "Sequence must be non-negative."); lock (_gate) { IEnumerable entries = ReadAll(shardId) .Where(entry => entry.Sequence > afterSequence) .OrderBy(entry => entry.Sequence); if (limit.HasValue) { entries = entries.Take(limit.Value); } return entries.ToArray(); } } /// public void Append(string shardId, CultNetShardLogEntryMessage entry) { if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId)); if (entry == null) throw new ArgumentNullException(nameof(entry)); if (entry.Sequence <= 0) throw new ArgumentOutOfRangeException(nameof(entry), "Log entry sequence must be positive."); lock (_gate) { var entries = ReadAll(shardId) .Where(existing => existing.Sequence != entry.Sequence) .Append(entry) .OrderBy(existing => existing.Sequence) .ToArray(); var payload = MessagePackSerializer.Serialize(entries, CultNetSchemaMessageSerialization.Options); File.WriteAllBytes(GetShardPath(shardId), payload); } } private CultNetShardLogEntryMessage[] ReadAll(string shardId) { var path = GetShardPath(shardId); if (!File.Exists(path)) { return Array.Empty(); } var payload = File.ReadAllBytes(path); return MessagePackSerializer.Deserialize( payload, CultNetSchemaMessageSerialization.Options) ?? Array.Empty(); } private string GetShardPath(string shardId) { return Path.Combine(_rootPath, HashShardId(shardId) + ".mpack"); } private static string HashShardId(string shardId) { using var sha = SHA256.Create(); var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(shardId)); return string.Concat(hash.Select(value => value.ToString("x2"))); } } }