2026-05-19 19:55:29 +01:00
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
using System.Security.Cryptography ;
using MessagePack ;
namespace GameCult.Networking
{
/// <summary>
/// Stores accepted shard-log entries in their replica catch-up wire shape.
/// </summary>
CultNetDatabase: log on the cache journal and publish from Watch, no queue
Cut 4b-4d of the publication map. The reorder buffer, drainer, prediction gate
and the logging inside the Watch handler are gone. The database registers the
Watch observer, then one cache journal. The journal runs under the gate in
Sequence order: it takes the door, decides the change kind and prediction
bookkeeping, logs (a primary mints, a replica records the primary's entry) and
stashes the change; the Watch observer publishes the stash entry by Sequence.
A primary that cannot log a committed change burns that sequence, raises its
own compaction floor, tries CompactThrough, and throws CultNetShardLogException
(not an InvalidOperationException) after publication. A replica's refused
append reaches the pull loop and the cursor does not advance.
Fixes four defects the map named: the log is appended to disk before memory
(and memory is a SortedList keyed by sequence); the snapshot reads its log
sequence before its documents; a restart resumes above the compaction point;
GetCompactedMutationLogSequence and GetLatestMutationLogSequence honour the
in-memory floor. The snapshot removal door sets Removal.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 23:48:50 +02:00
/// <remarks>
/// A database calls its store from a cache journal, under that cache's gate, so a slow store slows every writer of
2026-09-30 02:48:49 +02:00
/// the cache and blocks its readers too, because reads take the same gate. A store must not call the cache, or wait for a thread that does. Appends must throw rather than
CultNetDatabase: log on the cache journal and publish from Watch, no queue
Cut 4b-4d of the publication map. The reorder buffer, drainer, prediction gate
and the logging inside the Watch handler are gone. The database registers the
Watch observer, then one cache journal. The journal runs under the gate in
Sequence order: it takes the door, decides the change kind and prediction
bookkeeping, logs (a primary mints, a replica records the primary's entry) and
stashes the change; the Watch observer publishes the stash entry by Sequence.
A primary that cannot log a committed change burns that sequence, raises its
own compaction floor, tries CompactThrough, and throws CultNetShardLogException
(not an InvalidOperationException) after publication. A replica's refused
append reaches the pull loop and the cursor does not advance.
Fixes four defects the map named: the log is appended to disk before memory
(and memory is a SortedList keyed by sequence); the snapshot reads its log
sequence before its documents; a restart resumes above the compaction point;
GetCompactedMutationLogSequence and GetLatestMutationLogSequence honour the
in-memory floor. The snapshot removal door sets Removal.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 23:48:50 +02:00
/// swallow a failure: the database burns the sequence and compacts past it, and tells the writer.
/// </remarks>
2026-05-19 19:55:29 +01:00
public interface ICultNetShardMutationLogStore
{
/// <summary>
/// Reads accepted log entries for a shard after the supplied sequence.
/// </summary>
IReadOnlyList < CultNetShardLogEntryMessage > Read ( string shardId , long afterSequence = 0 , int? limit = null ) ;
/// <summary>
/// Appends or replaces one accepted log entry for a shard.
/// </summary>
void Append ( string shardId , CultNetShardLogEntryMessage entry ) ;
2026-05-19 20:53:45 +01:00
/// <summary>
/// Gets the highest sequence that has been compacted out of the retained log.
/// </summary>
long GetCompactedThrough ( string shardId ) ;
/// <summary>
/// Removes retained entries at or before the supplied sequence.
/// </summary>
void CompactThrough ( string shardId , long sequence ) ;
2026-05-19 19:55:29 +01:00
}
/// <summary>
/// Persists shard-log entries as one MessagePack file per shard.
/// </summary>
public sealed class CultNetFileShardMutationLogStore : ICultNetShardMutationLogStore
{
private readonly string _rootPath ;
private readonly object _gate = new ( ) ;
/// <summary>
/// Creates a file-backed shard mutation log store.
/// </summary>
public CultNetFileShardMutationLogStore ( string rootPath )
{
if ( string . IsNullOrWhiteSpace ( rootPath ) ) throw new ArgumentException ( "Value must be non-empty." , nameof ( rootPath ) ) ;
_rootPath = rootPath ;
Directory . CreateDirectory ( _rootPath ) ;
}
/// <inheritdoc />
public IReadOnlyList < CultNetShardLogEntryMessage > 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 < CultNetShardLogEntryMessage > entries = ReadAll ( shardId )
. Where ( entry = > entry . Sequence > afterSequence )
. OrderBy ( entry = > entry . Sequence ) ;
if ( limit . HasValue )
{
entries = entries . Take ( limit . Value ) ;
}
return entries . ToArray ( ) ;
}
}
/// <inheritdoc />
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 ) ;
}
}
2026-05-19 20:53:45 +01:00
/// <inheritdoc />
public long GetCompactedThrough ( string shardId )
{
if ( string . IsNullOrWhiteSpace ( shardId ) ) throw new ArgumentException ( "Value must be non-empty." , nameof ( shardId ) ) ;
lock ( _gate )
{
return ReadMetadata ( shardId ) . CompactedThrough ;
}
}
/// <inheritdoc />
public void CompactThrough ( string shardId , long sequence )
{
if ( string . IsNullOrWhiteSpace ( shardId ) ) throw new ArgumentException ( "Value must be non-empty." , nameof ( shardId ) ) ;
if ( sequence < 0 ) throw new ArgumentOutOfRangeException ( nameof ( sequence ) , "Sequence must be non-negative." ) ;
lock ( _gate )
{
var metadata = ReadMetadata ( shardId ) ;
if ( sequence < = metadata . CompactedThrough )
{
return ;
}
var retained = ReadAll ( shardId )
. Where ( entry = > entry . Sequence > sequence )
. OrderBy ( entry = > entry . Sequence )
. ToArray ( ) ;
File . WriteAllBytes (
GetShardPath ( shardId ) ,
MessagePackSerializer . Serialize ( retained , CultNetSchemaMessageSerialization . Options ) ) ;
metadata . CompactedThrough = sequence ;
WriteMetadata ( shardId , metadata ) ;
}
}
2026-05-19 19:55:29 +01:00
private CultNetShardLogEntryMessage [ ] ReadAll ( string shardId )
{
var path = GetShardPath ( shardId ) ;
if ( ! File . Exists ( path ) )
{
return Array . Empty < CultNetShardLogEntryMessage > ( ) ;
}
var payload = File . ReadAllBytes ( path ) ;
return MessagePackSerializer . Deserialize < CultNetShardLogEntryMessage [ ] > (
payload ,
CultNetSchemaMessageSerialization . Options ) ? ? Array . Empty < CultNetShardLogEntryMessage > ( ) ;
}
private string GetShardPath ( string shardId )
{
return Path . Combine ( _rootPath , HashShardId ( shardId ) + ".mpack" ) ;
}
2026-05-19 20:53:45 +01:00
private string GetMetadataPath ( string shardId )
{
return Path . Combine ( _rootPath , HashShardId ( shardId ) + ".meta.mpack" ) ;
}
private CultNetShardMutationLogMetadata ReadMetadata ( string shardId )
{
var path = GetMetadataPath ( shardId ) ;
if ( ! File . Exists ( path ) )
{
return new CultNetShardMutationLogMetadata ( ) ;
}
var payload = File . ReadAllBytes ( path ) ;
return MessagePackSerializer . Deserialize < CultNetShardMutationLogMetadata > (
payload ,
CultNetSchemaMessageSerialization . Options ) ? ? new CultNetShardMutationLogMetadata ( ) ;
}
private void WriteMetadata ( string shardId , CultNetShardMutationLogMetadata metadata )
{
var payload = MessagePackSerializer . Serialize ( metadata , CultNetSchemaMessageSerialization . Options ) ;
File . WriteAllBytes ( GetMetadataPath ( shardId ) , payload ) ;
}
2026-05-19 19:55:29 +01:00
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" ) ) ) ;
}
2026-05-19 20:53:45 +01:00
[MessagePackObject(AllowPrivate = true)]
internal sealed class CultNetShardMutationLogMetadata
{
[Key("compactedThrough")] public long CompactedThrough { get ; set ; }
}
2026-05-19 19:55:29 +01:00
}
}