2026-09-13 17:17:14 +02:00
#nullable enable
using System ;
using System.IO ;
2026-09-29 02:04:33 +02:00
using System.Linq ;
2026-09-13 17:17:14 +02:00
using System.Threading.Tasks ;
using GameCult.Caching ;
using GameCult.Caching.MessagePack ;
using NUnit.Framework ;
namespace GameCult.Caching.Tests
{
public class PreCut2StoreFormatTests
{
private static string FixtureRoot = >
Path . Combine ( TestContext . CurrentContext . TestDirectory , "Fixtures" , "pre-cut2" ) ;
private static string CopyFixture ( string name )
{
var target = Path . Combine ( Path . GetTempPath ( ) , $"cultlib-precut2-{Guid.NewGuid():N}" ) ;
var source = Path . Combine ( FixtureRoot , name ) ;
foreach ( var file in Directory . GetFiles ( source , "*" , SearchOption . AllDirectories ) )
{
var destination = Path . Combine ( target , Path . GetRelativePath ( source , file ) ) ;
Directory . CreateDirectory ( Path . GetDirectoryName ( destination ) ! ) ;
File . Copy ( file , destination ) ;
}
return target ;
}
private static void AssertFixtureRecords ( CultCache cache )
{
Assert . That ( cache . Get < PreCut2FixtureItem > ( new CultRecordKey ( "item:anvil" ) ) , Is . Not . Null ) ;
Assert . That ( cache . Get < PreCut2FixtureItem > ( new CultRecordKey ( "item:anvil" ) ) ! . Name , Is . EqualTo ( "anvil" ) ) ;
Assert . That ( cache . Get < PreCut2FixtureItem > ( new CultRecordKey ( "item:anvil" ) ) ! . Count , Is . EqualTo ( 3 ) ) ;
Assert . That ( cache . Get < PreCut2FixtureItem > ( new CultRecordKey ( "item:bellows" ) ) ? . Name , Is . EqualTo ( "bellows" ) ) ;
Assert . That ( cache . Get < PreCut2FixtureItem > ( new CultRecordKey ( "item:bellows" ) ) ? . Count , Is . EqualTo ( 42 ) ) ;
Assert . That ( cache . Get < PreCut2FixtureNote > ( new CultRecordKey ( "note:origin" ) ) ? . Text ,
Is . EqualTo ( "written before cut 2" ) ) ;
Assert . That ( cache . GetAll < PreCut2FixtureItem > ( ) , Has . Length . EqualTo ( 2 ) ) ;
Assert . That ( cache . GetAll < PreCut2FixtureNote > ( ) , Has . Length . EqualTo ( 1 ) ) ;
}
[Test]
public async Task DirectoryStoreOpensV4ManifestWrittenBeforeCut2 ( )
{
var root = CopyFixture ( "directory-v4" ) ;
try
{
var manifest = Path . Combine ( root , "store.cc" ) ;
2026-10-22 18:32:34 +02:00
Assert . That ( CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( manifest ) ) . FormatVersion ,
2026-09-13 17:17:14 +02:00
Is . EqualTo ( "cultcache.store.v4.directory-content-addressed-pages" ) ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new DirectoryMessagePackBackingStore ( manifest ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
AssertFixtureRecords ( cache ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
[Test]
public async Task SingleFileOpensV1SnapshotWrittenBeforeCut2 ( )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
Assert . That ( CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( file ) ) . FormatVersion ,
Is . EqualTo ( "cultcache.store.v1" ) ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
AssertFixtureRecords ( cache ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-09-29 02:04:33 +02:00
// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
private const string ItemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788" ;
private static string VectorPath ( string name )
{
for ( var dir = new DirectoryInfo ( TestContext . CurrentContext . TestDirectory ) ; dir ! = null ; dir = dir . Parent )
{
var candidate = Path . Combine ( dir . FullName , "tests" , "vectors" , "document-variants-c0" , name ) ;
if ( File . Exists ( candidate ) ) return candidate ;
}
throw new FileNotFoundException ( $"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}." ) ;
}
2026-09-29 02:35:57 +02:00
private static string Refusal ( string vector )
2026-09-29 02:04:33 +02:00
{
var root = Path . Combine ( Path . GetTempPath ( ) , $"cultlib-vector-{Guid.NewGuid():N}" ) ;
Directory . CreateDirectory ( root ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
File . Copy ( VectorPath ( vector ) , file ) ;
using var cache = new CultCache ( ) ;
2026-09-29 02:35:57 +02:00
var error = Assert . Catch ( ( ) = > cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ) ;
2026-09-29 02:04:33 +02:00
Assert . That ( error , Is . Not . Null ) ;
Assert . That ( error ! . ToString ( ) , Does . Contain ( nameof ( NotSupportedException ) ) ) ;
return error . ToString ( ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
[Test]
2026-09-29 02:35:57 +02:00
public void SingleFileRefusesUnknownHeaderByName ( )
2026-09-29 02:04:33 +02:00
{
2026-09-29 02:35:57 +02:00
Assert . That ( Refusal ( "unknown-header.msgpack" ) , Does . Contain ( "cultcache.store.v9" ) ) ;
2026-09-29 02:04:33 +02:00
}
2026-10-22 15:39:40 +02:00
// The release-now-c2a-bumps-format ruling rests on this: a store stamped with a newer format version is refused at
// open, so this runtime cannot rewrite it. The file stays byte-identical through the refusal and through later
// writes to the cache, whose refused store was never attached.
[TestCase("cultcache.store.v3")]
[TestCase("cultcache.store.v9")]
public async Task SingleFileStampedWithANewerFormatIsRefusedAndNeverRewritten ( string newerFormat )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( file ) ) ;
snapshot . FormatVersion = newerFormat ;
File . WriteAllBytes ( file , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
var before = File . ReadAllBytes ( file ) ;
using var cache = new CultCache ( ) ;
var error = Assert . Throws < NotSupportedException > ( ( ) = >
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ) ! ;
Assert . That ( error . Message , Does . Contain ( newerFormat ) ) ;
Assert . That ( cache . BackingStores , Is . Empty ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "written after the refusal" } ) ;
await cache . FlushAsync ( ) ;
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 16:43:11 +02:00
// Two processes: this one attached a v1 store, then a later build rewrote it as v3. A plain flush, a staged write
// and a staged delete must each refuse to downgrade it, leaving the file as the other process wrote it.
[Test]
public async Task SingleFileFlushRefusesAStoreAnotherProcessStampedNewerSinceAttach ( )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( file ) ) ;
snapshot . FormatVersion = "cultcache.store.v3" ;
File . WriteAllBytes ( file , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
var before = File . ReadAllBytes ( file ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "staged after the other process wrote v3" } ) ;
Assert . Throws < NotSupportedException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) ) ;
cache . Remove ( new CultRecordKey ( "item:anvil" ) ) ;
Assert . Throws < NotSupportedException > ( ( ) = > cache . BackingStores . Single ( ) . PushAll ( ) ) ;
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
// An unreadable stamp is not a newer format: a flush still heals a damaged store.
[Test]
public async Task SingleFileFlushStillHealsAStoreWhoseStampIsUnreadable ( )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
File . WriteAllBytes ( file , new byte [ ] { 0xc1 , 0xc1 } ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "heals" } ) ;
cache . FlushAllBackingStores ( ) ;
Assert . That ( CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( file ) ) . FormatVersion ,
Is . EqualTo ( "cultcache.store.v1" ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 17:30:57 +02:00
private static byte [ ] Raw ( params object [ ] parts )
{
var bytes = new System . Collections . Generic . List < byte > ( ) ;
foreach ( var part in parts )
{
switch ( part )
{
case byte single : bytes . Add ( single ) ; break ;
case string text :
var encoded = System . Text . Encoding . UTF8 . GetBytes ( text ) ;
bytes . Add ( 0xd9 ) ; bytes . Add ( ( byte ) encoded . Length ) ; bytes . AddRange ( encoded ) ; break ;
case byte [ ] binary :
bytes . Add ( 0xc4 ) ; bytes . Add ( ( byte ) binary . Length ) ; bytes . AddRange ( binary ) ; break ;
default : throw new ArgumentException ( part . GetType ( ) . Name ) ;
}
}
return bytes . ToArray ( ) ;
}
// The format decision comes from the header alone. A stamp this runtime does not read refuses whatever follows it,
// so a body that will not parse can never make a newer store look like damage a flush may heal.
private static System . Collections . Generic . IEnumerable < TestCaseData > NewerStampOverUnreadableBody ( )
{
byte garbage = 0xc1 ;
yield return new TestCaseData ( Raw ( ( byte ) 0x93 , "cultcache.store.v3" , garbage , garbage ) ) . SetName ( "v3 header over a bad body" ) ;
yield return new TestCaseData ( Raw ( ( byte ) 0x93 , System . Text . Encoding . UTF8 . GetBytes ( "cultcache.store.v3" ) , garbage , garbage ) ) . SetName ( "bin header v3 over a bad body" ) ;
yield return new TestCaseData ( Raw ( ( byte ) 0x93 , System . Text . Encoding . UTF8 . GetBytes ( "cultcache.store.v9" ) , garbage , garbage ) ) . SetName ( "bin header v9 over a bad body" ) ;
yield return new TestCaseData ( Raw ( ( byte ) 0x93 , "cultcache.store.v3" , ( byte ) 0x91 , garbage , ( byte ) 0x90 ) ) . SetName ( "v3 header over a bad catalog" ) ;
yield return new TestCaseData ( Raw ( ( byte ) 0x92 , "cultcache.store.v4" , garbage ) ) . SetName ( "v4 header over garbage" ) ;
}
[TestCaseSource(nameof(NewerStampOverUnreadableBody))]
public async Task SingleFileFlushRefusesANewerStampWhateverItsBodyHolds ( byte [ ] swapped )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
File . WriteAllBytes ( file , swapped ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "must not overwrite" } ) ;
Assert . Throws < NotSupportedException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( swapped ) ) ;
Assert . Throws < NotSupportedException > ( ( ) = > new SingleFileMessagePackBackingStore ( file ) . PullAll ( ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
[Test]
public async Task DirectoryWritesRefuseANewerStampWhateverTheManifestBodyHolds ( )
{
var root = CopyFixture ( "directory-v4" ) ;
try
{
var manifest = Path . Combine ( root , "store.cc" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new DirectoryMessagePackBackingStore ( manifest ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
var swapped = Raw ( ( byte ) 0x93 , "cultcache.store.v5.directory-content-addressed-pages" , ( byte ) 0xc1 , ( byte ) 0xc1 ) ;
File . WriteAllBytes ( manifest , swapped ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "must not overwrite" } ) ;
Assert . Throws < NotSupportedException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
Assert . That ( File . ReadAllBytes ( manifest ) , Is . EqualTo ( swapped ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
// A file that is read but carries no readable stamp (empty, or cut off) is damage, not a newer store: a flush heals it.
[TestCase(0)]
[TestCase(1)]
[TestCase(10)]
[TestCase(-1)]
public async Task SingleFileFlushHealsAnEmptyOrTruncatedFile ( int keep )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
var whole = File . ReadAllBytes ( file ) ;
File . WriteAllBytes ( file , whole . Take ( keep < 0 ? whole . Length / 2 : keep ) . ToArray ( ) ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "heals" } ) ;
cache . FlushAllBackingStores ( ) ;
var healed = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( file ) ) ;
Assert . That ( healed . FormatVersion , Is . EqualTo ( "cultcache.store.v1" ) ) ;
Assert . That ( healed . Records . Length , Is . GreaterThan ( 0 ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 17:00:10 +02:00
// A store subclass that overrides nothing beyond its four abstract members is guarded by the base, not by an opt-in.
private sealed class PlainCustomStore : SingleFileBackingStore
{
public PlainCustomStore ( string path ) : base ( path ) { }
protected override byte [ ] SerializeSnapshot ( CultPersistedStoreSnapshot snapshot ) = >
CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ;
protected override CultPersistedStoreSnapshot DeserializeSnapshot ( byte [ ] data )
{
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( data ) ;
CultDocumentMessagePackSerialization . RequireSingleFileFormat ( snapshot ) ;
return snapshot ;
}
protected override byte [ ] SerializePayload ( object document ) = >
CultDocumentMessagePackSerialization . SerializeUntyped ( document , document . GetType ( ) , Registry ) ;
protected override object DeserializePayload ( Type documentType , byte [ ] payload ) = >
CultDocumentMessagePackSerialization . DeserializeUntyped ( documentType , payload , Registry ) ;
}
private static void StampHeader ( string file , string header )
{
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( file ) ) ;
snapshot . FormatVersion = header ;
File . WriteAllBytes ( file , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
}
[Test]
public async Task CustomSingleFileStoreThatOverridesNothingIsRefusedAtFlushOfANewerStore ( )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new PlainCustomStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
StampHeader ( file , "cultcache.store.v3" ) ;
var before = File . ReadAllBytes ( file ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "staged" } ) ;
Assert . Throws < NotSupportedException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
// Attach, commit and flush share one rule for which formats are readable: a header the reader refuses at attach is
// refused at flush, whatever its spelling.
[TestCase("cultcache.store.v3-ids")]
[TestCase("cultcache.store.v1000000")]
[TestCase("cultcache.v3")]
[TestCase("cultcache.store.v3")]
[TestCase("cultcache.store.v2")]
[TestCase("cultcache.store.v1")]
public async Task FlushRefusesExactlyTheHeadersAttachRefuses ( string header )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
StampHeader ( file , header ) ;
var before = File . ReadAllBytes ( file ) ;
bool attachRefuses ;
using ( var fresh = new CultCache ( ) )
{
try { fresh . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ; attachRefuses = false ; }
catch ( NotSupportedException ) { attachRefuses = true ; }
}
await cache . AddAsync ( new PreCut2FixtureNote { Text = "staged" } ) ;
var flushRefuses = false ;
try { cache . FlushAllBackingStores ( ) ; }
catch ( NotSupportedException ) { flushRefuses = true ; }
Assert . That ( flushRefuses , Is . EqualTo ( attachRefuses ) , header ) ;
if ( attachRefuses ) Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) , header ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 18:32:34 +02:00
// The refusal comes from the read, not from a failed write onto the held file: the store never reaches serialising.
private sealed class WriteCountingStore : SingleFileMessagePackBackingStore
{
public int Serialisations ;
public WriteCountingStore ( string path ) : base ( path ) { }
protected override byte [ ] SerializeSnapshot ( CultPersistedStoreSnapshot snapshot )
{
Serialisations + + ;
return base . SerializeSnapshot ( snapshot ) ;
}
}
// The public parser judges no stamp, as in 1.0.61: a v4 directory manifest and a v3 header both parse.
[Test]
public void PublicParserReadsAnyStampAsEveryReleaseHas ( )
{
var root = CopyFixture ( "directory-v4" ) ;
try
{
var manifest = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( Path . Combine ( root , "store.cc" ) ) ) ;
Assert . That ( manifest . FormatVersion , Is . EqualTo ( "cultcache.store.v4.directory-content-addressed-pages" ) ) ;
var v3 = new CultPersistedStoreSnapshot { FormatVersion = "cultcache.store.v3" , SchemaCatalog = Array . Empty < CultSchemaCatalogEntry > ( ) , Records = Array . Empty < CultPersistedRecord > ( ) } ;
var parsed = CultDocumentMessagePackSerialization . DeserializeSnapshot ( CultDocumentMessagePackSerialization . SerializeSnapshot ( v3 ) ) ;
Assert . That ( parsed . FormatVersion , Is . EqualTo ( "cultcache.store.v3" ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 17:00:10 +02:00
// A read that fails on I/O is not an unreadable stamp to heal over: the flush refuses and the file is untouched.
[Test]
public async Task SingleFileFlushRefusesWhenReadingTheDiskFailsOnIO ( )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
2026-10-22 18:32:34 +02:00
var store = new WriteCountingStore ( file ) ;
cache . AddBackingStore ( store ) ;
2026-10-22 17:00:10 +02:00
await cache . PullAllBackingStoresAsync ( ) ;
var before = File . ReadAllBytes ( file ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "staged" } ) ;
2026-10-22 18:32:34 +02:00
store . Serialisations = 0 ;
2026-10-22 17:33:24 +02:00
using ( new FileStream ( file , FileMode . Open , FileAccess . ReadWrite , FileShare . None ) )
{
Assert . Throws < IOException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
}
2026-10-22 18:32:34 +02:00
Assert . That ( store . Serialisations , Is . Zero , "the refusal is the read failing, not the write onto the held file failing" ) ;
2026-10-22 17:00:10 +02:00
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 16:43:11 +02:00
[TestCase("cultcache.store.v5.directory-content-addressed-pages")]
[TestCase("cultcache.store.v9")]
public async Task DirectoryWritesRefuseAStoreAnotherProcessStampedNewerSinceAttach ( string newer )
{
var root = CopyFixture ( "directory-v4" ) ;
try
{
var manifest = Path . Combine ( root , "store.cc" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new DirectoryMessagePackBackingStore ( manifest ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
2026-10-22 18:32:34 +02:00
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( manifest ) ) ;
2026-10-22 16:43:11 +02:00
snapshot . FormatVersion = newer ;
File . WriteAllBytes ( manifest , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
Func < System . Collections . Generic . Dictionary < string , byte [ ] > > files = ( ) = > Directory
. GetFiles ( root , "*" , SearchOption . AllDirectories )
. Where ( path = > ! path . EndsWith ( ".lock" , StringComparison . Ordinal ) )
. ToDictionary ( path = > path , File . ReadAllBytes ) ;
var before = files ( ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "staged after the other process wrote the newer format" } ) ;
Assert . Throws < NotSupportedException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
Assert . That ( files ( ) . Keys , Is . EquivalentTo ( before . Keys ) ) ;
foreach ( var pair in before ) Assert . That ( File . ReadAllBytes ( pair . Key ) , Is . EqualTo ( pair . Value ) , pair . Key ) ;
cache . Remove ( new CultRecordKey ( "item:anvil" ) ) ;
Assert . Throws < NotSupportedException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
Assert . That ( files ( ) . Keys , Is . EquivalentTo ( before . Keys ) ) ;
foreach ( var pair in before ) Assert . That ( File . ReadAllBytes ( pair . Key ) , Is . EqualTo ( pair . Value ) , pair . Key ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-09-29 02:04:33 +02:00
[Test]
2026-09-29 02:35:57 +02:00
public void SingleFileRefusesExtraRecordSlotNamingTheRecord ( )
2026-09-29 02:04:33 +02:00
{
2026-09-29 02:35:57 +02:00
var message = Refusal ( "extra-slot-full-payload.msgpack" ) ;
2026-09-29 02:04:33 +02:00
Assert . That ( message , Does . Contain ( "item:anvil" ) ) ;
Assert . That ( message , Does . Contain ( ItemSchemaId ) ) ;
}
2026-09-29 03:52:35 +02:00
// C1 reads v2. This vector was written by hand before C1 existed and its variant keeps its base's name, so a
// cache with a codec refuses it under R6, naming both records; a cache without one cannot resolve it at all.
2026-09-29 02:04:33 +02:00
[Test]
2026-09-29 03:52:35 +02:00
public void SingleFileRefusesTheHandWrittenVariantVectorNamingTheRecords ( )
2026-09-29 02:04:33 +02:00
{
2026-09-29 03:52:35 +02:00
var root = Path . Combine ( Path . GetTempPath ( ) , $"cultlib-vector-{Guid.NewGuid():N}" ) ;
Directory . CreateDirectory ( root ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
File . Copy ( VectorPath ( "variant-v2.msgpack" ) , file ) ;
using var withCodec = new CultCache ( CultDocumentRegistry . Shared , CultCacheMessagePack . CreateCodec ( CultDocumentRegistry . Shared ) ) ;
var named = Assert . Throws < InvalidOperationException > ( ( ) = > withCodec . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ) ! ;
Assert . That ( named . Message , Does . Contain ( "item:anvil-big" ) . And . Contain ( "item:anvil" ) ) ;
using var without = new CultCache ( ) ;
var codecless = Assert . Throws < InvalidOperationException > ( ( ) = > without . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ) ! ;
Assert . That ( codecless . Message , Does . Contain ( "item:anvil-big" ) . And . Contain ( "codec" ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
2026-09-29 02:04:33 +02:00
}
2026-09-29 02:35:57 +02:00
[Test]
public void SnapshotWithoutAFormatVersionIsRefusedNotDefaultedToV1 ( )
{
// [nil, [], []]
var bytes = new byte [ ] { 0x93 , 0xc0 , 0x90 , 0x90 } ;
Assert . That ( ( ) = > CultDocumentMessagePackSerialization . DeserializeSnapshot ( bytes ) ,
Throws . TypeOf < NotSupportedException > ( ) . With . Message . Contains ( "format version" ) ) ;
}
2026-09-30 17:05:15 +02:00
// Every runtime reads a store file the same way: a missing file is an empty store, an existing empty file is not
// a store (no CultCache writer leaves one), and an empty array carries no header, so it claims no format and is an
// empty store too.
[Test]
public async Task SingleFileRefusesAnEmptyFileAndOpensAMissingFileOrAnEmptyArrayAsEmpty ( )
{
var root = Path . Combine ( Path . GetTempPath ( ) , $"cultlib-empty-{Guid.NewGuid():N}" ) ;
Directory . CreateDirectory ( root ) ;
try
{
var empty = Path . Combine ( root , "empty.msgpack" ) ;
File . WriteAllBytes ( empty , Array . Empty < byte > ( ) ) ;
using ( var cache = new CultCache ( ) )
{
Assert . Catch ( ( ) = > cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( empty ) ) ) ;
Assert . That ( cache . BackingStores , Is . Empty ) ;
}
Assert . That ( File . ReadAllBytes ( empty ) , Is . Empty ) ;
var emptyArray = Path . Combine ( root , "empty-array.msgpack" ) ;
File . WriteAllBytes ( emptyArray , new byte [ ] { 0x90 } ) ;
foreach ( var path in new [ ] { Path . Combine ( root , "missing.msgpack" ) , emptyArray } )
{
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( path ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
Assert . That ( cache . GetAll < PreCut2FixtureItem > ( ) , Is . Empty , path ) ;
}
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-09-30 18:35:20 +02:00
// A refusal echoes a header only in the shape cultcache.store.v<digits>; any other is described by its length.
[TestCase("cultcache.store.v12", true)]
[TestCase("cultcache.store.SECRET-HEADER", false)]
[TestCase("cultcache.store.v12SECRET", false)]
[TestCase("cultcache.store.v", false)]
public void SingleFileRefusalEchoesAHeaderOnlyInTheKnownShape ( string header , bool echoed )
{
var snapshot = new CultPersistedStoreSnapshot { FormatVersion = header } ;
var error = Assert . Throws < NotSupportedException > ( ( ) = > CultDocumentMessagePackSerialization . RequireSingleFileFormat ( snapshot ) ) ! ;
if ( echoed )
{
Assert . That ( error . Message , Does . Contain ( "format " + header + " is not" ) ) ;
}
else
{
Assert . That ( error . Message , Does . Not . Contain ( "SECRET" ) . And . Not . Contain ( "format " + header + " is not" ) ) ;
Assert . That ( error . Message , Does . Contain ( $"of {header.Length} bytes" ) ) ;
}
}
2026-09-29 02:04:33 +02:00
[Test]
public void V1StoreWrittenAtTheBaseCommitStillDecodesByteForByte ( )
{
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( VectorPath ( "v1-base.msgpack" ) ) ) ;
CultDocumentMessagePackSerialization . RequireSingleFileFormat ( snapshot ) ;
Assert . That ( snapshot . FormatVersion , Is . EqualTo ( "cultcache.store.v1" ) ) ;
Assert . That ( snapshot . Records . Select ( record = > record . Key ) , Is . EqualTo ( new [ ] { "alpha" , "beta" } ) ) ;
Assert . That ( snapshot . Records [ 0 ] . Payload , Is . EqualTo ( new byte [ ] { 0x92 , 0xa5 , ( byte ) 'a' , ( byte ) 'l' , ( byte ) 'p' , ( byte ) 'h' , ( byte ) 'a' , 0x01 } ) ) ;
Assert . That ( snapshot . Records [ 1 ] . Payload , Is . EqualTo ( new byte [ ] { 0x92 , 0xa4 , ( byte ) 'b' , ( byte ) 'e' , ( byte ) 't' , ( byte ) 'a' , 0x02 } ) ) ;
}
2026-09-13 17:17:14 +02:00
// Format strings taken from DirectoryMessagePackBackingStore.cs at 0db1fe5.
[TestCase("cultcache.store.v1")]
[TestCase("cultcache.store.v1.directory")]
[TestCase("cultcache.store.v2.directory-indexed")]
[TestCase("cultcache.store.v3.directory-immutable-pages")]
public void DirectoryStoreRefusesNonV4Manifest ( string formatVersion )
{
var root = Path . Combine ( Path . GetTempPath ( ) , $"cultlib-refuse-{Guid.NewGuid():N}" ) ;
var manifest = Path . Combine ( root , "store.cc" ) ;
Directory . CreateDirectory ( root ) ;
try
{
var bytes = CultDocumentMessagePackSerialization . SerializeSnapshot ( new CultPersistedStoreSnapshot
{
FormatVersion = formatVersion ,
SchemaCatalog = Array . Empty < CultSchemaCatalogEntry > ( ) ,
Records = Array . Empty < CultPersistedRecord > ( )
} ) ;
File . WriteAllBytes ( manifest , bytes ) ;
using var cache = new CultCache ( ) ;
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
Assert . That ( ( ) = > cache . AddBackingStore ( new DirectoryMessagePackBackingStore ( manifest ) ) ,
2026-09-13 17:17:14 +02:00
Throws . TypeOf < InvalidOperationException > ( ) . With . Message . Contains ( formatVersion ) ) ;
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
Assert . That ( cache . BackingStores , Is . Empty ) ;
2026-09-13 17:17:14 +02:00
Assert . That ( File . ReadAllBytes ( manifest ) , Is . EqualTo ( bytes ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
}
}