diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 458c7f58..8fe2dfb9 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -31,6 +32,16 @@ namespace GameCult.Mesh /// public CultNetDatabaseOptions? DatabaseOptions { get; set; } + /// + /// Gets or sets whether CultMesh should attach a file-backed authoritative shard-log store when none is supplied. + /// + public bool EnableDurableShardLogs { get; set; } + + /// + /// Gets or sets the directory used for file-backed authoritative shard logs. + /// + public string? ShardLogPath { get; set; } + /// /// Gets or sets the database server bridge options used for shard routing and forwarding. /// @@ -41,18 +52,54 @@ namespace GameCult.Mesh /// public Action? ConfigureServer { get; set; } - internal CultNetHostOptions ToCultNetOptions() + internal CultNetHostOptions ToCultNetOptions(string cachePath) { return new CultNetHostOptions { CacheOptions = CacheOptions, Security = Security, StartServer = StartServer, - DatabaseOptions = DatabaseOptions, + DatabaseOptions = CreateDatabaseOptions(cachePath), DatabaseServerOptions = DatabaseServerOptions, ConfigureServer = ConfigureServer }; } + + private CultNetDatabaseOptions? CreateDatabaseOptions(string cachePath) + { + if (!EnableDurableShardLogs) + { + return DatabaseOptions; + } + + var source = DatabaseOptions ?? new CultNetDatabaseOptions(); + if (source.MutationLogStore != null) + { + return source; + } + + return new CultNetDatabaseOptions + { + RuntimeId = source.RuntimeId, + Shards = source.Shards, + ClientAuthorityScopes = source.ClientAuthorityScopes, + DocumentRegistry = source.DocumentRegistry, + MutationLogStore = new CultNetFileShardMutationLogStore(ResolveShardLogPath(cachePath)) + }; + } + + private string ResolveShardLogPath(string cachePath) + { + if (!string.IsNullOrWhiteSpace(ShardLogPath)) + { + return ShardLogPath!; + } + + var fullPath = Path.GetFullPath(cachePath); + var directory = Path.GetDirectoryName(fullPath) ?? Directory.GetCurrentDirectory(); + var name = Path.GetFileNameWithoutExtension(fullPath); + return Path.Combine(directory, name + ".cultmesh", "shard-logs"); + } } /// @@ -127,7 +174,7 @@ namespace GameCult.Mesh { var host = await CultNetLocal.CreateHostAsync( cachePath, - (options ?? new CultMeshNodeOptions()).ToCultNetOptions()).ConfigureAwait(false); + (options ?? new CultMeshNodeOptions()).ToCultNetOptions(cachePath)).ConfigureAwait(false); return new CultMeshNode(host); } diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md index a926c180..d9dc240b 100644 --- a/src/GameCult.Mesh/README.md +++ b/src/GameCult.Mesh/README.md @@ -26,6 +26,19 @@ var player = await node.Database.GetAsync(playerKey); await node.Database.PutAsync(playerKey, player); ``` +Enable durable authoritative shard logs when a node should serve replica +catch-up after restart: + +```csharp +using var node = await CultMesh.StartNodeAsync("world.ccmp", new CultMeshNodeOptions +{ + EnableDurableShardLogs = true +}); +``` + +If `ShardLogPath` is omitted, CultMesh stores logs beside the cache under +`world.cultmesh/shard-logs`. + ## Verses A Verse is a rule-bearing consensus graph. Aetheria Main can be a diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 1c4fff05..21e22ae3 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -21,6 +21,11 @@ Namespace: `GameCult.Mesh` - `Database`: the distributed realtime database facade - `DatabaseServer`: the schema-v0 database bridge +`CultMeshNodeOptions.EnableDurableShardLogs` attaches a file-backed +authoritative shard-log store when `DatabaseOptions.MutationLogStore` is not +already supplied. `ShardLogPath` can override the location; otherwise CultMesh +uses a `.cultmesh/shard-logs` directory beside the cache file. + The package home and primary entrypoint are `GameCult.Mesh` / `CultMesh`. Some lower-level database and wire-contract types still retain `CultNet` names because they are shared with the transport package. Treat those as plumbing, diff --git a/src/GameCult.Networking/Contracts/cultnet-distributed-database.md b/src/GameCult.Networking/Contracts/cultnet-distributed-database.md index d620ca3d..c19a13ef 100644 --- a/src/GameCult.Networking/Contracts/cultnet-distributed-database.md +++ b/src/GameCult.Networking/Contracts/cultnet-distributed-database.md @@ -67,6 +67,8 @@ Implemented: - shard-bounded snapshots with shard id, epoch, and represented log sequence - replica snapshot recovery after compacted history, including local shard replacement and cursor advancement +- CultMesh node option for file-backed authoritative shard logs with a default + `.cultmesh/shard-logs` path beside the cache file - client-authority scopes for locally predicted input documents - predicted and reconciled database change kinds for client-side prediction - simulation witness observations for mesh-side opinions about frame facts @@ -119,7 +121,6 @@ Not implemented yet: Add discovery fanout and operational polish: -- expose operator-facing defaults for durable shard-log paths in CultMesh nodes - add Verse discovery over the schema-v0 mesh lane - add snapshot throttling and size limits for public mesh edges - after that, add simulation-frame rollback/resimulation helpers around diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 6c662033..668883ea 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -792,6 +792,64 @@ namespace GameCult.Networking.Tests Assert.That(incompatible.CanTransferFrom(aetheria), Is.False); } + [Test] + public async Task CultMeshNode_CanEnable_DurableShardLogs_WithDefaultPath() + { + var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-node", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootPath); + var cachePath = Path.Combine(rootPath, "world.ccmp"); + var cache = new CultCache(); + var schemaId = cache.Registry.GetRequired().SchemaId; + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument( + cache.Registry, + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + var options = new CultMeshNodeOptions + { + StartServer = false, + EnableDurableShardLogs = true, + DatabaseOptions = new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = + [ + new CultNetShardDescriptor( + "players-mesh-default-log", + "mesh-runtime", + epoch: 1, + isPrimary: true, + schemaIds: [schemaId], + keyPrefix: "player:") + ] + } + }; + var key = new CultRecordKey("player:mesh-log"); + var player = new PlayerData + { + PlayerId = Guid.NewGuid(), + Email = "mesh-log@example.test", + PasswordHash = "hash", + Username = "MeshLog" + }; + + using (var node = await CultMesh.CreateNodeAsync(cachePath, options)) + { + await node.Database.PutAsync(key, player); + } + + using var restarted = await CultMesh.CreateNodeAsync(cachePath, options); + var response = restarted.DatabaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage + { + ShardId = "players-mesh-default-log" + }); + + Assert.That(response.Entries, Has.Length.EqualTo(1)); + Assert.That(response.Entries[0].Put, Is.Not.Null); + Assert.That(response.Entries[0].Put!.Document.RecordKey, Is.EqualTo(key.Value)); + Assert.That(Directory.Exists(Path.Combine(rootPath, "world.cultmesh", "shard-logs")), Is.True); + } + [Test] public async Task CultNetDatabase_Appends_PerShardMutationLog() {