C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
#nullable enable
using System ;
using System.IO ;
using System.Linq ;
using GameCult.Caching.MessagePack ;
using NUnit.Framework ;
using static GameCult . Caching . Tests . EmittedDocumentTypes ;
namespace GameCult.Caching.Tests
{
// A record under a schema id no registered type owns or lists as compatible is foreign. CultCache never destroys or relabels
// it: both store kinds carry it byte for byte, list it as foreign, and refuse any write that would replace or remove it.
public class ForeignRecordTests
{
private static readonly Type Deck = Emit ( "ForeignSuiteDeck" , "tests.foreign_deck" , "tests.foreign_deck.v1" , new [ ] { new Field ( "Name" , typeof ( string ) , 0 , IsName : true ) } ) ;
2026-10-03 12:34:58 +02:00
// The deck's schema declaring another runtime's id compatible (OtherRuntimeId below): one under the deck's own version, and a
// later version that a registry holding the deck can register beside it.
private static readonly Type ClaimingDeck = Emit ( "ForeignSuiteClaimingDeck" , "tests.foreign_deck" , "tests.foreign_deck.v1" , new [ ] { new Field ( "Name" , typeof ( string ) , 0 , IsName : true ) } , new [ ] { "other.runtime.deck" } ) ;
private static readonly Type LateClaimingDeck = Emit ( "ForeignSuiteLateClaimingDeck" , "tests.foreign_deck" , "tests.foreign_deck.v2" , new [ ] { new Field ( "Name" , typeof ( string ) , 0 , IsName : true ) } , new [ ] { "other.runtime.deck" } ) ;
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
private static readonly Type Widget = Emit ( "ForeignSuiteWidget" , "tests.foreign_widget" , "tests.foreign_widget.v1" , new [ ] { new Field ( "Label" , typeof ( string ) , 0 , IsName : true ) } ) ;
2026-10-12 07:39:32 +02:00
// A second type a cache registers, so that a second backing store has a home of its own.
private static readonly Type Gizmo = Emit ( "ForeignSuiteGizmo" , "tests.foreign_gizmo" , "tests.foreign_gizmo.v1" , new [ ] { new Field ( "Tag" , typeof ( string ) , 0 , IsName : true ) } ) ;
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
// A build that has both types, and one that has only the deck: to it the widget is foreign.
private static readonly CultDocumentRegistry Full = CultDocumentRegistry . ForTypes ( new [ ] { Deck , Widget } ) ;
private static readonly CultDocumentRegistry DeckOnly = CultDocumentRegistry . ForTypes ( new [ ] { Deck } ) ;
private static readonly string WidgetId = Full . GetRequired ( Widget ) . SchemaId ;
2026-10-03 13:11:38 +02:00
// Another writer's deck (an older or newer build, another runtime) under an id the deck does not declare. It declares the
2026-10-27 03:46:27 +01:00
// deck's own id compatible, so it may rewrite the deck's records; the deck still reads them through that listing, but only by
// dropping the Note slot it lacks, so it does not own them and holds them read-only.
private static readonly Type MovingDeck = Emit ( "ForeignSuiteMovingDeck" , "tests.foreign_deck" , "tests.foreign_deck.v3" , new [ ] { new Field ( "Name" , typeof ( string ) , 0 , IsName : true ) , new Field ( "Note" , typeof ( string ) , 1 ) } , new [ ] { DeckOnly . GetRequired ( Deck ) . SchemaId } ) ;
2026-10-03 13:11:38 +02:00
private static readonly CultDocumentRegistry Moving = CultDocumentRegistry . ForTypes ( new [ ] { MovingDeck } ) ;
private static readonly string MovingId = Moving . GetRequired ( MovingDeck ) . SchemaId ;
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
private static readonly CultRecordKey D = new ( "d" ) ;
private static readonly CultRecordKey E = new ( "e" ) ;
private static readonly CultRecordKey W = new ( "w" ) ;
2026-10-03 12:34:58 +02:00
private static readonly CultRecordKey V = new ( "v" ) ;
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
private const string Canary = "CANARY-7f3c-payload-value" ;
public enum Write
{
Flush ,
Commit ,
ConditionalCommit
}
private string _directory = "" ;
[SetUp]
public void SetUp ( )
{
_directory = Path . Combine ( Path . GetTempPath ( ) , "cultlib-foreign-" + Guid . NewGuid ( ) . ToString ( "N" ) ) ;
Directory . CreateDirectory ( _directory ) ;
}
[TearDown]
public void TearDown ( )
{
if ( Directory . Exists ( _directory ) )
Directory . Delete ( _directory , true ) ;
}
private string PathOf ( string name ) = > Path . Combine ( _directory , name ) ;
private static CultCache Open ( string path , CultDocumentRegistry registry , bool directory ) = >
CultCacheMessagePack . Create ( path , new CultCacheOpenOptions { Registry = registry , UseDirectoryStore = directory } ) ;
private static object DeckOf ( string name ) = > New ( Deck , ( "Name" , name ) ) ;
private static object WidgetOf ( string label ) = > New ( Widget , ( "Label" , label ) ) ;
private static CultPersistedStoreSnapshot Read ( string path ) = >
CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( path ) ) ;
// Every file of the store, by name, with its bytes: a refused write leaves all of them as they were.
private static string [ ] Fingerprint ( string path )
{
var files = new [ ] { path } . Concat ( Directory . Exists ( DirectoryMessagePackBackingStore . DefaultRecordDirectoryPath ( path ) )
? Directory . GetFiles ( DirectoryMessagePackBackingStore . DefaultRecordDirectoryPath ( path ) , "*.msgpack" )
: Array . Empty < string > ( ) )
. OrderBy ( file = > file , StringComparer . Ordinal ) ;
return files . Select ( file = > Path . GetFileName ( file ) + ":" + Convert . ToBase64String ( File . ReadAllBytes ( file ) ) ) . ToArray ( ) ;
}
// What the store holds for one record as the file (single-file) or manifest (directory: the page hash) names it.
private static string Stored ( string path , CultRecordKey key )
{
var record = Read ( path ) . Records . Single ( candidate = > candidate . Key = = key . Value ) ;
return $"{record.SchemaId}|{record.StoredAt}|{Convert.ToBase64String(record.Payload)}|{record.Variant == null}" ;
}
// A store holding a deck d and a widget w, written by the build that has both types.
private string Seeded ( string name , bool directory )
{
var path = PathOf ( name ) ;
using var cache = Open ( path , Full , directory ) ;
cache . Commit ( batch = >
{
batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ;
batch . Upsert ( Widget , WidgetOf ( "w" ) , W ) ;
} ) ;
return path ;
}
private static CacheBackingStore StoreOf ( CultCache cache ) = > cache . BackingStores . Single ( ) ;
private static void Land ( CultCache cache , Write write , CultRecordKey key , object document )
{
switch ( write )
{
case Write . Flush :
cache . UpsertAsync ( document . GetType ( ) , document , key ) . GetAwaiter ( ) . GetResult ( ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
break ;
case Write . Commit :
Assert . That ( cache . Commit ( batch = > batch . Upsert ( document . GetType ( ) , document , key ) ) , Is . True ) ;
break ;
case Write . ConditionalCommit :
Assert . That ( cache . Commit ( batch = >
{
batch . Expect ( D , cache . Get ( D ) ) ;
batch . Upsert ( document . GetType ( ) , document , key ) ;
} ) , Is . True ) ;
break ;
}
}
private static void Refused ( CultCache cache , Write write , CultRecordKey key , object document , CultRecordKey foreignKey )
{
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > Land ( cache , write , key , document ) ) ! ;
Assert . Multiple ( ( ) = >
{
Assert . That ( refusal . RecordKey , Is . EqualTo ( foreignKey . Value ) ) ;
Assert . That ( refusal . SchemaId , Is . EqualTo ( WidgetId ) ) ;
Assert . That ( refusal . SchemaNames , Is . EqualTo ( new [ ] { "tests.foreign_widget" } ) ) ;
Assert . That ( refusal . Message , Does . Contain ( foreignKey . Value ) . And . Contain ( WidgetId ) ) ;
Assert . That ( refusal . Message , Does . Not . Contain ( Canary ) , "a refusal never echoes the document it refused" ) ;
} ) ;
}
[TestCase(false)]
[TestCase(true)]
public void AForeignRecordLoadsAsForeignAndTheCacheDoesNotHoldIt ( bool directory )
{
var path = Seeded ( "seeded.cc" , directory ) ;
var storedAt = Read ( path ) . Records . Single ( record = > record . Key = = W . Value ) . StoredAt ;
using var cache = Open ( path , DeckOnly , directory ) ;
var foreign = StoreOf ( cache ) . ForeignRecords . Single ( ) ;
Assert . Multiple ( ( ) = >
{
Assert . That ( foreign . Key , Is . EqualTo ( W . Value ) ) ;
Assert . That ( foreign . SchemaId , Is . EqualTo ( WidgetId ) ) ;
Assert . That ( foreign . SchemaName , Is . EqualTo ( "tests.foreign_widget" ) ) ;
Assert . That ( foreign . StoredAt , Is . EqualTo ( storedAt ) ) ;
Assert . That ( cache . Get ( W ) , Is . Null , "the cache never holds a foreign record" ) ;
Assert . That ( EmittedDocumentTypes . Read ( cache . Get ( D ) ! , "Name" ) , Is . EqualTo ( "d" ) , "the records it claims load as usual" ) ;
} ) ;
}
// Every write path lays the foreign record back as the file holds it: the single-file store's whole view (a flush and an
// unconditional commit), its commit onto the file, and the directory store's generation.
[Test]
public void AWriteOfAnotherRecordKeepsTheForeignRecordByteForByte (
[Values(false, true)] bool directory ,
[Values] Write write )
{
var path = Seeded ( "kept.cc" , directory ) ;
var before = Stored ( path , W ) ;
using ( var cache = Open ( path , DeckOnly , directory ) )
Land ( cache , write , E , DeckOf ( "e" ) ) ;
Assert . That ( Stored ( path , W ) , Is . EqualTo ( before ) ) ;
Assert . That ( Read ( path ) . Records . Select ( record = > record . Key ) , Is . EquivalentTo ( new [ ] { "d" , "e" , "w" } ) ) ;
using var full = Open ( path , Full , directory ) ;
Assert . That ( EmittedDocumentTypes . Read ( full . Get ( W ) ! , "Label" ) , Is . EqualTo ( "w" ) , "the build that has the type still reads it" ) ;
Assert . That ( StoreOf ( full ) . ForeignRecords , Is . Empty ) ;
}
[Test]
public void AWriteThatWouldReplaceAForeignRecordIsRefusedAndNothingIsWritten (
[Values(false, true)] bool directory ,
[Values] Write write )
{
var path = Seeded ( "replace.cc" , directory ) ;
var before = Fingerprint ( path ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
Refused ( cache , write , W , DeckOf ( Canary ) , W ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
Assert . That ( cache . Get ( W ) , Is . Null ) ;
}
// Another writer replaced a record this cache holds with a record of a type it does not have. A write that would lay this
2026-10-03 15:13:07 +02:00
// cache's record over it is refused; the file keeps the other writer's record. The refusal reloads it as a pull would: the
// store lists it foreign, so the next write of it is refused before staging.
[Test]
public void AWriteOverARecordAnotherWriterMadeForeignIsRefused ( [ Values ] bool directory , [ Values ( Write . Flush , Write . Commit ) ] Write write )
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
{
var path = Seeded ( "replaced.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
var before = Fingerprint ( path ) ;
2026-10-03 15:13:07 +02:00
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > Land ( cache , write , D , DeckOf ( Canary ) ) ) ! ;
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
Assert . That ( refusal . RecordKey , Is . EqualTo ( D . Value ) ) ;
Assert . That ( refusal . Message , Does . Not . Contain ( Canary ) ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
2026-10-03 15:13:07 +02:00
Assert . That ( StoreOf ( cache ) . ForeignRecords . Select ( record = > record . Key ) , Is . EqualTo ( new [ ] { "d" , "w" } ) ) ;
Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . UpsertAsync ( Deck , DeckOf ( Canary ) , D ) . GetAwaiter ( ) . GetResult ( ) ) ;
Assert . That ( cache . IsDirty , Is . False ) ;
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
}
2026-10-02 16:51:54 +02:00
// A refusal does not poison the store: what the cache held for the refused record goes with the refusal, so a later write
// to another record lands and the file keeps the other writer's record.
[Test]
public void AfterARefusedWriteAWriteToAnotherRecordLands (
[Values(false, true)] bool directory ,
[Values(Write.Flush, Write.Commit)] Write refused )
{
var path = Seeded ( "unstuck.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
var widget = Stored ( path , D ) ;
Assert . Throws < CultSchemaConflictException > ( ( ) = > Land ( cache , refused , D , DeckOf ( Canary ) ) ) ;
Land ( cache , Write . Commit , E , DeckOf ( "e" ) ) ;
Assert . That ( Stored ( path , D ) , Is . EqualTo ( widget ) , "the other writer's record is as it was" ) ;
Assert . That ( EmittedDocumentTypes . Read ( cache . Get ( E ) ! , "Name" ) , Is . EqualTo ( "e" ) ) ;
Assert . That ( Read ( path ) . Records . Select ( record = > record . Key ) , Does . Contain ( "e" ) ) ;
}
2026-10-02 18:34:09 +02:00
// The cache and the store agree on what a refusal left: the refused record is not served, not after a later write, not after
// a pull, and not by a fresh open. What the cache served before the refusal was never what the file holds.
[Test]
public void ARefusedRecordIsNotServedAfterTheRefusalAfterALaterWriteOrAfterAPull (
[Values(false, true)] bool directory ,
[Values(Write.Flush, Write.Commit)] Write refused )
{
var path = Seeded ( "not-served.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
2026-10-02 18:47:47 +02:00
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > Land ( cache , refused , D , DeckOf ( Canary ) ) ) ! ;
Assert . That ( refusal . Message , Does . StartWith ( "Record 'd' (schema id '" + WidgetId + "'" ) , "one refused key reads as one record" ) ;
2026-10-02 18:34:09 +02:00
var afterRefusal = cache . Get ( D ) ;
Land ( cache , Write . Commit , E , DeckOf ( "e" ) ) ;
var afterLaterWrite = cache . Get ( D ) ;
cache . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
var afterPull = cache . Get ( D ) ;
using var fresh = Open ( path , DeckOnly , directory ) ;
Assert . Multiple ( ( ) = >
{
Assert . That ( afterRefusal , Is . Null , "after the refusal" ) ;
Assert . That ( afterLaterWrite , Is . Null , "after a later write" ) ;
Assert . That ( afterPull , Is . Null , "after a pull" ) ;
Assert . That ( fresh . Get ( D ) , Is . Null , "a fresh open" ) ;
Assert . That ( StoreOf ( cache ) . ForeignRecords . Select ( record = > record . Key ) , Is . EqualTo ( StoreOf ( fresh ) . ForeignRecords . Select ( record = > record . Key ) ) ) ;
} ) ;
}
// A refusal names every key it refused, and every one of them leaves the cache; a record the write did not touch stays.
[Test]
public void ARefusalOfSeveralRecordsNamesEachAndTheCacheDropsEach (
[Values(false, true)] bool directory ,
[Values(Write.Flush, Write.Commit)] Write refused )
{
var path = PathOf ( "several.cc" ) ;
var d2 = new CultRecordKey ( "d2" ) ;
using ( var seed = Open ( path , Full , directory ) )
seed . Commit ( batch = >
{
batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ;
batch . Upsert ( Deck , DeckOf ( "d2" ) , d2 ) ;
batch . Upsert ( Deck , DeckOf ( "e" ) , E ) ;
} ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = >
{
batch . Upsert ( Widget , WidgetOf ( "w1" ) , D ) ;
batch . Upsert ( Widget , WidgetOf ( "w2" ) , d2 ) ;
} ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = >
{
if ( refused = = Write . Flush )
{
cache . UpsertAsync ( Deck , DeckOf ( Canary ) , D ) . GetAwaiter ( ) . GetResult ( ) ;
cache . UpsertAsync ( Deck , DeckOf ( Canary ) , d2 ) . GetAwaiter ( ) . GetResult ( ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
}
else
{
cache . Commit ( batch = >
{
batch . Upsert ( Deck , DeckOf ( Canary ) , D ) ;
batch . Upsert ( Deck , DeckOf ( Canary ) , d2 ) ;
} ) ;
}
} ) ! ;
cache . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . Multiple ( ( ) = >
{
2026-10-02 18:47:47 +02:00
Assert . That ( refusal . RecordKey , Is . EqualTo ( "d" ) , "the first refused key in key order" ) ;
Assert . That ( refusal . Message , Does . Contain ( "Records 'd' (schema id '" + WidgetId + "'" ) . And . Contain ( "'d2' (schema id '" + WidgetId + "'" ) . And . Not . Contain ( Canary ) ) ;
2026-10-02 18:34:09 +02:00
Assert . That ( cache . Get ( D ) , Is . Null ) ;
Assert . That ( cache . Get ( d2 ) , Is . Null ) ;
Assert . That ( EmittedDocumentTypes . Read ( cache . Get ( E ) ! , "Name" ) , Is . EqualTo ( "e" ) , "a record the write did not touch stays" ) ;
} ) ;
}
// A write that never touches a record another writer made foreign is not refused for holding a stale copy of it: it lands
// the first time, in both store kinds, and the next pull drops the copy.
[Test]
public void AWriteThatNeverTouchedARecordAnotherWriterMadeForeignLandsAtOnce (
[Values(false, true)] bool directory ,
[Values(Write.Flush, Write.Commit)] Write write )
{
var path = Seeded ( "untouched.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
var widget = Stored ( path , D ) ;
Land ( cache , write , E , DeckOf ( "e" ) ) ;
cache . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . Multiple ( ( ) = >
{
Assert . That ( Stored ( path , D ) , Is . EqualTo ( widget ) , "the other writer's record is as it was" ) ;
Assert . That ( Read ( path ) . Records . Select ( record = > record . Key ) , Does . Contain ( "e" ) ) ;
Assert . That ( cache . Get ( D ) , Is . Null , "the pull drops the stale copy" ) ;
} ) ;
}
2026-10-02 18:47:47 +02:00
// The write carried the file's record for d; it did not store this cache's copy, so the copy's stamp is still what the
// cache read, and a condition on it still fails against the other writer's record.
[Test]
public void ACarriedForeignRecordDoesNotRestampTheStaleCopyTheCacheHolds (
[Values(false, true)] bool directory ,
[Values(Write.Flush, Write.Commit)] Write write )
{
var path = Seeded ( "restamp.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
Land ( cache , write , E , DeckOf ( "e" ) ) ;
Assert . That ( cache . Commit ( batch = >
{
batch . Expect ( D , cache . Get ( D ) ) ;
batch . Upsert ( Deck , DeckOf ( "e2" ) , E ) ;
} ) , Is . False , "the cache never read the other writer's record" ) ;
}
2026-10-02 16:51:54 +02:00
// The same when the stale copy was only held, never written: a flush after the other writer's record landed refuses once.
[Test]
public void AFlushRefusedForAStaleCopyOfAForeignRecordRefusesOnce ( [ Values ( false , true ) ] bool directory )
{
var path = Seeded ( "stale.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
var widget = Stored ( path , D ) ;
Assert . Throws < CultSchemaConflictException > ( ( ) = > Land ( cache , Write . Commit , D , DeckOf ( Canary ) ) ) ;
Land ( cache , Write . Flush , E , DeckOf ( "e" ) ) ;
Land ( cache , Write . Flush , E , DeckOf ( "e again" ) ) ;
Assert . That ( Stored ( path , D ) , Is . EqualTo ( widget ) ) ;
}
2026-10-02 18:47:47 +02:00
// A key staged and not yet written is refused by any write that carries it, whatever key that write names: an unconditional
// commit writes what a flush would.
2026-10-02 17:58:50 +02:00
[Test]
2026-10-02 18:47:47 +02:00
public void AStagedWriteIsRefusedByACommitOfAnotherRecord ( [ Values ( false , true ) ] bool directory )
2026-10-02 17:58:50 +02:00
{
2026-10-02 18:47:47 +02:00
var path = Seeded ( "staged-commit.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
var widget = Stored ( path , D ) ;
cache . UpsertAsync ( Deck , DeckOf ( Canary ) , D ) . GetAwaiter ( ) . GetResult ( ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "e" ) , E ) ) ) ! ;
Assert . That ( refusal . RecordKey , Is . EqualTo ( D . Value ) ) ;
Assert . That ( Stored ( path , D ) , Is . EqualTo ( widget ) ) ;
}
// What a write landed is no longer staged: a record that becomes foreign afterwards is the other writer's, and writing
// another record is not refused for it.
[Test]
public void AWrittenRecordIsNoLongerStagedSoALaterForeignRecordDoesNotRefuseOtherWrites (
[Values(false, true)] bool directory ,
[Values(Write.Flush, Write.Commit)] Write landed )
{
var path = Seeded ( "landed.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
Land ( cache , landed , D , DeckOf ( "d again" ) ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
var widget = Stored ( path , D ) ;
Land ( cache , Write . Commit , E , DeckOf ( "e" ) ) ;
Assert . That ( Stored ( path , D ) , Is . EqualTo ( widget ) ) ;
Assert . That ( Read ( path ) . Records . Select ( record = > record . Key ) , Does . Contain ( "e" ) ) ;
}
2026-10-02 18:52:42 +02:00
// A commit writes the whole view, so what it landed that was staged is no longer staged either.
[Test]
public void AStagedWriteLandedByACommitIsNoLongerStaged ( [ Values ( false , true ) ] bool directory )
{
var path = Seeded ( "staged-landed.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
cache . UpsertAsync ( Deck , DeckOf ( "d staged" ) , D ) . GetAwaiter ( ) . GetResult ( ) ;
Land ( cache , Write . Commit , E , DeckOf ( "e" ) ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
var widget = Stored ( path , D ) ;
Land ( cache , Write . Commit , new CultRecordKey ( "e2" ) , DeckOf ( "e2" ) ) ;
Assert . That ( Stored ( path , D ) , Is . EqualTo ( widget ) ) ;
Assert . That ( Read ( path ) . Records . Select ( record = > record . Key ) , Does . Contain ( "e2" ) ) ;
}
2026-10-02 18:47:47 +02:00
// A staged removal is a write of its own: a refusal takes it too.
[TestCase(false)]
[TestCase(true)]
public void AStagedRemovalOfARecordAnotherWriterMadeForeignIsRefusedOnceAndThenGone ( bool directory )
{
var path = Seeded ( "staged-removal.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var other = Open ( path , Full , directory ) )
2026-10-02 17:58:50 +02:00
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
var widget = Stored ( path , D ) ;
cache . Remove ( D ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ) ! ;
Land ( cache , Write . Commit , E , DeckOf ( "e" ) ) ;
Assert . That ( refusal . RecordKey , Is . EqualTo ( D . Value ) ) ;
Assert . That ( Stored ( path , D ) , Is . EqualTo ( widget ) ) ;
}
// What a refusal leaves staged is exactly what it did not refuse: the other record's write still lands on the next flush,
// and a store with nothing left staged is clean.
2026-10-02 18:47:47 +02:00
[TestCase(false)]
[TestCase(true)]
public void ARefusalLeavesStagedWhatItDidNotRefuse ( bool directory )
2026-10-02 17:58:50 +02:00
{
2026-10-02 18:47:47 +02:00
var path = Seeded ( "pending.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var other = Open ( path , Full , directory ) )
2026-10-02 17:58:50 +02:00
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
cache . UpsertAsync ( Deck , DeckOf ( Canary ) , D ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ) ;
Assert . That ( cache . IsDirty , Is . False , "nothing is left staged" ) ;
2026-10-03 15:18:21 +02:00
cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "e" ) , E ) ) ;
cache . UpsertAsync ( Deck , DeckOf ( Canary ) , E ) . GetAwaiter ( ) . GetResult ( ) ;
cache . UpsertAsync ( Deck , DeckOf ( "v" ) , V ) . GetAwaiter ( ) . GetResult ( ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , E ) ) ;
2026-10-02 17:58:50 +02:00
Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ) ;
2026-10-03 15:18:21 +02:00
Assert . That ( cache . IsDirty , Is . True , "v is still staged" ) ;
2026-10-02 17:58:50 +02:00
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
2026-10-03 15:18:21 +02:00
Assert . That ( Read ( path ) . Records . Select ( record = > record . Key ) , Does . Contain ( "v" ) ) ;
2026-10-02 17:58:50 +02:00
Assert . That ( cache . IsDirty , Is . False ) ;
}
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
[TestCase(false)]
[TestCase(true)]
public void RemovingARecordAnotherWriterMadeForeignIsRefused ( bool directory )
{
var path = Seeded ( "removed.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "e" ) , E ) ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
var before = Fingerprint ( path ) ;
// A condition keeps the single-file commit on the file as it is, so only the removal reaches d.
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . Commit ( batch = >
{
batch . Expect ( E , cache . Get ( E ) ) ;
batch . Remove ( D ) ;
} ) ) ! ;
Assert . That ( refusal . RecordKey , Is . EqualTo ( D . Value ) ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
}
[TestCase(false)]
[TestCase(true)]
public void ARecordAnotherWriterMadeForeignLeavesTheCacheAtThePull ( bool directory )
{
var path = Seeded ( "pulled.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , D ) ) ;
cache . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( cache . Get ( D ) , Is . Null ) ;
Assert . That ( StoreOf ( cache ) . ForeignRecords . Select ( record = > record . Key ) , Is . EqualTo ( new [ ] { "d" , "w" } ) ) ;
}
2026-09-30 22:59:52 +02:00
// A directory load without a lease that a writer's commit forces to retry lists each foreign record once, from the manifest
// the retry read.
[Test]
public void ARetriedDirectoryLoadListsEachForeignRecordOnce ( )
{
var path = Seeded ( "retried.cc" , directory : true ) ;
var store = new DirectoryMessagePackBackingStore ( path ) ;
using var cache = new CultCache ( DeckOnly ) ;
cache . AddBackingStore ( store ) ;
File . Delete ( Path . Combine ( DirectoryMessagePackBackingStore . DefaultRecordDirectoryPath ( path ) , ".commit.lock" ) ) ;
var commits = 0 ;
// The writer is another cache, so it commits from another thread; it commits once, under the first unleased read.
store . UnleasedManifestRead = ( ) = >
{
if ( commits + + > 0 )
return ;
System . Threading . Tasks . Task . Run ( ( ) = >
{
using var writer = Open ( path , Full , directory : true ) ;
writer . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "e" ) , E ) ) ;
} ) . GetAwaiter ( ) . GetResult ( ) ;
} ;
cache . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( cache . Get ( E ) , Is . Not . Null , "the load retried and read the writer's generation" ) ;
Assert . That ( store . ForeignRecords . Select ( record = > record . Key ) , Is . EqualTo ( new [ ] { "w" } ) ) ;
}
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
// The file decides what is carried, not what this cache loaded: a foreign record another writer added since is kept too.
[TestCase(false)]
[TestCase(true)]
public void AForeignRecordAddedAfterTheLoadIsKept ( bool directory )
{
var path = Seeded ( "added.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
var z = new CultRecordKey ( "z" ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "z" ) , z ) ) ;
var before = Stored ( path , z ) ;
Land ( cache , Write . Flush , E , DeckOf ( "e" ) ) ;
Assert . That ( Stored ( path , z ) , Is . EqualTo ( before ) ) ;
}
// A writer cannot see whether a foreign record holds element ids, so a store marked as holding them stays marked while it
2026-10-03 07:09:28 +02:00
// carries one: a write that copies any record forward keeps the mark.
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
[Test]
2026-10-03 07:09:28 +02:00
public void AMarkedHeaderStaysMarkedWhileTheStoreCarriesAForeignRecord ( [ Values ( Write . Flush , Write . Commit ) ] Write write )
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
{
var path = Seeded ( "marked.cc" , directory : false ) ;
var snapshot = Read ( path ) ;
snapshot . FormatVersion = CultPersistedStoreSnapshot . FormatV3 ;
File . WriteAllBytes ( path , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
using ( var cache = Open ( path , DeckOnly , directory : false ) )
Land ( cache , write , E , DeckOf ( "e" ) ) ;
2026-10-03 07:09:28 +02:00
Assert . That ( Read ( path ) . FormatVersion , Is . EqualTo ( CultPersistedStoreSnapshot . FormatV3 ) ) ;
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
}
2026-09-30 21:20:11 +02:00
private const string OtherRuntimeId = "other.runtime.deck" ;
2026-10-27 03:46:27 +01:00
// A deck another runtime wrote under its own id for the deck's schema, with a slot this build lacks. This build reads it
// through the catalog's schema name, but only by dropping that slot, so it does not own the id.
2026-10-03 12:34:58 +02:00
private string OtherRuntimeStore ( string name , bool withVariant = false )
2026-09-30 21:20:11 +02:00
{
var path = PathOf ( name ) ;
using ( var cache = Open ( path , DeckOnly , directory : false ) )
2026-10-03 12:34:58 +02:00
{
2026-09-30 21:20:11 +02:00
cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ) ;
2026-10-03 12:34:58 +02:00
if ( withVariant )
cache . Commit ( batch = > batch . UpsertVariant ( V , D , new [ ] { cache . Override ( Deck , "Name" , "v" ) } ) ) ;
}
2026-09-30 21:20:11 +02:00
var snapshot = Read ( path ) ;
var entry = snapshot . SchemaCatalog . Single ( ) ;
entry . SchemaId = OtherRuntimeId ;
entry . ContentHash = OtherRuntimeId ;
entry . CompatibleSchemaIds = new [ ] { OtherRuntimeId } ;
2026-10-27 03:46:27 +01:00
entry . Members = entry . Members . Append ( new CultSchemaMemberCatalogEntry { Slot = 99 , MemberName = "Dropped" , TypeName = "System.String" } ) . ToArray ( ) ;
2026-10-03 12:34:58 +02:00
foreach ( var record in snapshot . Records )
record . SchemaId = OtherRuntimeId ;
2026-09-30 22:59:52 +02:00
// An older entry that also lists the id, first in the catalog: the entry that owns the id is the one that publishes it.
snapshot . SchemaCatalog = new [ ]
{
new CultSchemaCatalogEntry
{
SchemaId = "decoy.older" , SchemaName = "tests.decoy" , SchemaVersion = "tests.decoy.v1" , ContentHash = "decoy.older" ,
CompatibleSchemaIds = new [ ] { "decoy.older" , OtherRuntimeId }
} ,
entry
} ;
2026-09-30 21:20:11 +02:00
File . WriteAllBytes ( path , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
return path ;
}
2026-09-30 22:59:52 +02:00
// A store whose file is gone holds nothing, foreign records included.
[Test]
public void AStoreWhoseFileIsGoneListsNoForeignRecords ( )
{
var path = Seeded ( "gone.cc" , directory : false ) ;
using var cache = Open ( path , DeckOnly , directory : false ) ;
File . Delete ( path ) ;
cache . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( StoreOf ( cache ) . ForeignRecords , Is . Empty ) ;
}
// The registry's public resolution still refuses an id no registered type claims, naming the schema and the id: only a store
// carries such a record.
[Test]
public void ResolvingAnUnclaimedIdThroughTheRegistryRefusesByName ( )
{
var entry = Full . GetRequired ( Widget ) . ToCatalogEntry ( ) ;
var refusal = Assert . Throws < InvalidOperationException > ( ( ) = > DeckOnly . ResolvePersistedSchema ( WidgetId , new [ ] { entry } ) ) ! ;
Assert . That ( refusal . Message , Does . Contain ( "tests.foreign_widget" ) . And . Contain ( WidgetId ) ) ;
}
2026-09-30 21:20:11 +02:00
private static byte [ ] CatalogEntryBytes ( string path , string schemaId ) = >
CultDocumentMessagePackSerialization . SerializeSnapshot ( new CultPersistedStoreSnapshot
{
SchemaCatalog = Read ( path ) . SchemaCatalog . Where ( entry = > entry . SchemaId = = schemaId ) . ToArray ( )
} ) ;
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
// A write of another record copies a record under an id this build does not own as the store holds it: its id, storedAt,
// bytes and the catalog entry that publishes its id.
[Test]
public void AWriteOfAnotherRecordCopiesARecordUnderAnIdThisBuildDoesNotOwn ( [ Values ] Write write )
{
var path = OtherRuntimeStore ( "other-runtime.cc" ) ;
var before = Stored ( path , D ) ;
var entry = CatalogEntryBytes ( path , OtherRuntimeId ) ;
using ( var cache = Open ( path , DeckOnly , directory : false ) )
{
Assert . That ( EmittedDocumentTypes . Read ( cache . Get ( D ) ! , "Name" ) , Is . EqualTo ( "d" ) , "this build reads it" ) ;
Land ( cache , write , E , DeckOf ( "e" ) ) ;
}
Assert . That ( Stored ( path , D ) , Is . EqualTo ( before ) ) ;
Assert . That ( CatalogEntryBytes ( path , OtherRuntimeId ) , Is . EqualTo ( entry ) ) ;
2026-10-03 12:34:58 +02:00
using var reopened = Open ( path , DeckOnly , directory : false ) ;
Assert . That ( EmittedDocumentTypes . Read ( reopened . Get ( E ) ! , "Name" ) , Is . EqualTo ( "e" ) , "the write beside the read-only record landed" ) ;
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
}
// The entry that publishes a copied record is the one the store holds when the write lands, not the one the record loaded with.
[Test]
public void ACopiedRecordIsPublishedByTheCatalogEntryTheStoreHoldsNow ( [ Values ( Write . Flush , Write . Commit ) ] Write write )
{
var path = OtherRuntimeStore ( "redescribed.cc" ) ;
using var cache = Open ( path , DeckOnly , directory : false ) ;
var snapshot = Read ( path ) ;
snapshot . SchemaCatalog . Single ( entry = > entry . SchemaId = = OtherRuntimeId ) . SchemaVersion = "tests.foreign_deck.redescribed" ;
File . WriteAllBytes ( path , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
var current = CatalogEntryBytes ( path , OtherRuntimeId ) ;
Land ( cache , write , E , DeckOf ( "e" ) ) ;
Assert . That ( CatalogEntryBytes ( path , OtherRuntimeId ) , Is . EqualTo ( current ) ) ;
}
2026-10-03 12:44:21 +02:00
// A write over a foreign record is refused at the write itself, in both store kinds, not first at the flush: nothing is staged
// and the cache never holds the record.
[Test]
public void AWriteOverAForeignRecordIsRefusedBeforeStaging ( [ Values ] bool directory )
{
var path = Seeded ( directory ? "staged-dir.cc" : "staged-file.cc" , directory ) ;
var before = Fingerprint ( path ) ;
using ( var cache = Open ( path , DeckOnly , directory ) )
{
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . UpsertAsync ( Deck , DeckOf ( Canary ) , W ) . GetAwaiter ( ) . GetResult ( ) ) ! ;
Assert . That ( ( refusal . RecordKey , refusal . SchemaId ) , Is . EqualTo ( ( W . Value , WidgetId ) ) ) ;
Assert . That ( refusal . Message , Does . Not . Contain ( Canary ) ) ;
Assert . That ( cache . IsDirty , Is . False , "nothing was staged" ) ;
Assert . That ( cache . Get ( W ) , Is . Null ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
}
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
}
2026-10-03 12:34:58 +02:00
// A record read through the catalog's schema name under an id this build neither owns nor declares is read-only: a write of
// it is refused typed before anything is staged, the store is left byte for byte, and the record is still served.
2026-09-30 21:20:11 +02:00
[Test]
2026-10-03 12:34:58 +02:00
public void ARecordReadByNameIsReadOnly ( [ Values ( Write . Flush , Write . Commit ) ] Write write )
2026-09-30 21:20:11 +02:00
{
2026-10-03 12:34:58 +02:00
var path = OtherRuntimeStore ( "read-only.cc" ) ;
var before = Fingerprint ( path ) ;
2026-09-30 21:20:11 +02:00
using ( var cache = Open ( path , DeckOnly , directory : false ) )
2026-10-03 12:34:58 +02:00
{
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > Land ( cache , write , D , DeckOf ( "changed" ) ) ) ! ;
Assert . That ( ( refusal . RecordKey , refusal . SchemaId ) , Is . EqualTo ( ( D . Value , OtherRuntimeId ) ) ) ;
Assert . That ( refusal . SchemaNames , Is . EqualTo ( new [ ] { "tests.foreign_deck" } ) , "it names the type that reads it" ) ;
Assert . That ( cache . IsDirty , Is . False ) ;
Assert . That ( EmittedDocumentTypes . Read ( cache . Get ( D ) ! , "Name" ) , Is . EqualTo ( "d" ) ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
}
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
}
public enum ReadOnlyWrite
{
Upsert ,
UpsertVariantAtItsKey ,
Flatten ,
Remove ,
CommitUpsert ,
CommitRemove
}
// Every cache write path refuses a staged write or removal of a read-only record before staging: the typed conflict, nothing
// dirty, the store byte for byte after a later flush, and the record still served.
[Test]
public void EveryWritePathRefusesAReadOnlyRecord ( [ Values ] ReadOnlyWrite path )
{
var file = OtherRuntimeStore ( "every-path-" + path + ".cc" , withVariant : true ) ;
var before = Fingerprint ( file ) ;
using ( var cache = Open ( file , DeckOnly , directory : false ) )
{
var key = path is ReadOnlyWrite . UpsertVariantAtItsKey or ReadOnlyWrite . Flatten ? V : D ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = >
{
switch ( path )
{
case ReadOnlyWrite . Upsert :
cache . UpsertAsync ( Deck , DeckOf ( "changed" ) , D ) . GetAwaiter ( ) . GetResult ( ) ;
break ;
case ReadOnlyWrite . UpsertVariantAtItsKey :
cache . UpsertVariantAsync ( V , D , new [ ] { cache . Override ( Deck , "Name" , "changed" ) } ) . GetAwaiter ( ) . GetResult ( ) ;
break ;
case ReadOnlyWrite . Flatten :
cache . FlattenAsync ( V ) . GetAwaiter ( ) . GetResult ( ) ;
break ;
case ReadOnlyWrite . Remove :
cache . Remove ( D ) ;
break ;
case ReadOnlyWrite . CommitUpsert :
cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "changed" ) , D ) ) ;
break ;
case ReadOnlyWrite . CommitRemove :
cache . Commit ( batch = > batch . Remove ( D ) ) ;
break ;
}
} ) ! ;
Assert . That ( ( refusal . RecordKey , refusal . SchemaId ) , Is . EqualTo ( ( key . Value , OtherRuntimeId ) ) ) ;
Assert . That ( cache . IsDirty , Is . False , "nothing was staged" ) ;
Assert . That ( EmittedDocumentTypes . Read ( cache . Get ( D ) ! , "Name" ) , Is . EqualTo ( "d" ) ) ;
Assert . That ( EmittedDocumentTypes . Read ( cache . Get ( V ) ! , "Name" ) , Is . EqualTo ( "v" ) ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
}
Assert . That ( Fingerprint ( file ) , Is . EqualTo ( before ) ) ;
}
// A type that declares the id compatible owns the record: its write lands under the type's own id and reopens.
[Test]
public void DeclaringTheIdClaimsTheRecord ( )
{
var path = OtherRuntimeStore ( "declared.cc" ) ;
var claiming = CultDocumentRegistry . ForTypes ( new [ ] { ClaimingDeck } ) ;
using ( var cache = Open ( path , claiming , directory : false ) )
cache . Commit ( batch = > batch . Upsert ( ClaimingDeck , New ( ClaimingDeck , ( "Name" , "claimed" ) ) , D ) ) ;
Assert . That ( Read ( path ) . Records . Single ( ) . SchemaId , Is . EqualTo ( claiming . GetRequired ( ClaimingDeck ) . SchemaId ) ) ;
using var reopened = Open ( path , claiming , directory : false ) ;
Assert . That ( EmittedDocumentTypes . Read ( reopened . Get ( D ) ! , "Name" ) , Is . EqualTo ( "claimed" ) ) ;
}
// Whether a record is read-only is read live from the registry at the write, not fixed at load: a type registered after the
2026-10-03 13:12:06 +02:00
// load that declares the id makes the record writable, and the write stores it under the writing type's id, not the declarer's.
2026-10-03 12:34:58 +02:00
[Test]
2026-10-03 13:12:06 +02:00
public void ATypeRegisteredAfterLoadThatDeclaresTheIdMakesItWritable ( )
2026-10-03 12:34:58 +02:00
{
var path = OtherRuntimeStore ( "late.cc" ) ;
var registry = CultDocumentRegistry . ForTypes ( new [ ] { Deck } ) ;
using ( var cache = Open ( path , registry , directory : false ) )
{
Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "refused" ) , D ) ) ) ;
registry . GetRequired ( LateClaimingDeck ) ;
cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "claimed" ) , D ) ) ;
}
2026-09-30 21:20:11 +02:00
2026-10-03 12:34:58 +02:00
Assert . That ( Read ( path ) . Records . Single ( ) . SchemaId , Is . EqualTo ( registry . GetRequired ( Deck ) . SchemaId ) ) ;
2026-09-30 21:20:11 +02:00
using var reopened = Open ( path , DeckOnly , directory : false ) ;
2026-10-03 12:34:58 +02:00
Assert . That ( EmittedDocumentTypes . Read ( reopened . Get ( D ) ! , "Name" ) , Is . EqualTo ( "claimed" ) ) ;
2026-09-30 21:20:11 +02:00
}
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
// A commit onto the file that involves a variant is judged on the set the file will hold. A foreign record is not decoded
// for that judgement, so it neither fails the commit nor joins the set.
[Test]
public void ACommitInvolvingAVariantIsNotRefusedByAForeignRecord ( )
{
var path = Seeded ( "variant.cc" , directory : false ) ;
var before = Stored ( path , W ) ;
using var cache = Open ( path , DeckOnly , directory : false ) ;
var v = new CultRecordKey ( "v" ) ;
cache . UpsertVariantAsync ( v , D , new [ ] { cache . Override ( Deck , "Name" , "v" ) } ) . GetAwaiter ( ) . GetResult ( ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Land ( cache , Write . ConditionalCommit , E , DeckOf ( "e" ) ) ;
Assert . That ( Stored ( path , W ) , Is . EqualTo ( before ) ) ;
Assert . That ( Read ( path ) . Records . Select ( record = > record . Key ) , Is . EquivalentTo ( new [ ] { "d" , "e" , "v" , "w" } ) ) ;
}
// A variant's base that another writer replaced with a foreign record is gone from the set the file will hold, so a commit
// onto that file is refused as it would be if the base had been removed.
[Test]
public void ACommitOntoAFileWhereAForeignRecordReplacedAVariantsBaseIsRefused ( )
{
var path = Seeded ( "base.cc" , directory : false ) ;
using var cache = Open ( path , DeckOnly , directory : false ) ;
var v = new CultRecordKey ( "v" ) ;
cache . UpsertVariantAsync ( v , D , new [ ] { cache . Override ( Deck , "Name" , "v" ) } ) . GetAwaiter ( ) . GetResult ( ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
var snapshot = Read ( path ) ;
var widget = snapshot . Records . Single ( record = > record . Key = = W . Value ) ;
snapshot . Records = snapshot . Records
. Select ( record = > record . Key = = D . Value
? new CultPersistedRecord { Key = D . Value , SchemaId = widget . SchemaId , StoredAt = widget . StoredAt , Payload = widget . Payload }
: record )
. ToArray ( ) ;
File . WriteAllBytes ( path , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
var before = Fingerprint ( path ) ;
var refusal = Assert . Throws ( Is . InstanceOf < InvalidOperationException > ( ) , ( ) = > cache . Commit ( batch = >
{
batch . Expect ( E , null ) ;
batch . Upsert ( Deck , DeckOf ( "e" ) , E ) ;
} ) ) ! ;
Assert . That ( refusal . Message , Does . Contain ( v . Value ) . And . Contain ( D . Value ) ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
}
2026-10-03 13:11:38 +02:00
// The other writer rewrites a record under its own id, which this build reads but does not declare.
private static void Move ( string path , CultRecordKey key , string name , bool directory )
{
using var other = Open ( path , Moving , directory ) ;
Assert . That ( other . Commit ( batch = > batch . Upsert ( MovingDeck , New ( MovingDeck , ( "Name" , name ) ) , key ) ) , Is . True ) ;
}
private static void RefusedOnDisk ( CultSchemaConflictException refusal , CultRecordKey key )
{
Assert . Multiple ( ( ) = >
{
Assert . That ( ( refusal . RecordKey , refusal . SchemaId ) , Is . EqualTo ( ( key . Value , MovingId ) ) ) ;
Assert . That ( refusal . SchemaNames , Is . EqualTo ( new [ ] { "tests.foreign_deck" } ) ) ;
Assert . That ( refusal . Message , Does . Not . Contain ( Canary ) , "a refusal never echoes the document it refused" ) ;
} ) ;
}
// The staging half cannot see a record another writer moved to an undeclared id after this cache loaded it: the write is
// refused where it lands, under the store's lock, in both store kinds. Nothing is written, and the cache reloads the record as
// the store holds it, read-only, so the next write of it is refused before staging.
[Test]
public void AWriteOverARecordAnotherWriterMovedToAnUndeclaredIdIsRefusedAndReloadedReadOnly (
[Values] bool directory ,
[Values(Write.Flush, Write.Commit)] Write write )
{
var path = PathOf ( "moved.cc" ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ) ;
if ( write = = Write . Flush )
cache . UpsertAsync ( Deck , DeckOf ( Canary ) , D ) . GetAwaiter ( ) . GetResult ( ) ;
Move ( path , D , "moved" , directory ) ;
var before = Fingerprint ( path ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = >
{
if ( write = = Write . Flush )
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
else
cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( Canary ) , D ) ) ;
} ) ! ;
RefusedOnDisk ( refusal , D ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
Assert . That ( EmittedDocumentTypes . Read ( cache . Get ( D ) ! , "Name" ) , Is . EqualTo ( "moved" ) , "the cache serves what the store holds" ) ;
Assert . That ( cache . IsDirty , Is . False ) ;
RefusedOnDisk ( Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . UpsertAsync ( Deck , DeckOf ( Canary ) , D ) . GetAwaiter ( ) . GetResult ( ) ) ! , D ) ;
Assert . That ( cache . IsDirty , Is . False , "the second write was refused at staging" ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
}
[Test]
public void AStagedRemovalOfARecordMovedToAnUndeclaredIdIsRefused ( [ Values ] bool directory )
{
var path = PathOf ( "moved-removal.cc" ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ) ;
Assert . That ( cache . Remove ( D ) , Is . True ) ;
Move ( path , D , "moved" , directory ) ;
var moved = Stored ( path , D ) ;
RefusedOnDisk ( Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ) ! , D ) ;
Assert . That ( Stored ( path , D ) , Is . EqualTo ( moved ) ) ;
Assert . That ( EmittedDocumentTypes . Read ( cache . Get ( D ) ! , "Name" ) , Is . EqualTo ( "moved" ) ) ;
Assert . That ( cache . IsDirty , Is . False ) ;
}
// A key this cache never held, which another writer created under an undeclared id after the load: the write is refused, and
// the cache then holds the record read-only.
[Test]
public void ARecordThatAppearedUnderAnUndeclaredIdAtAWrittenKeyIsRefusedAndHeld ( [ Values ] bool directory )
{
var path = PathOf ( "appeared.cc" ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "e" ) , E ) ) ;
Move ( path , D , "appeared" , directory ) ;
var before = Fingerprint ( path ) ;
cache . UpsertAsync ( Deck , DeckOf ( Canary ) , D ) . GetAwaiter ( ) . GetResult ( ) ;
RefusedOnDisk ( Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ) ! , D ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
Assert . That ( EmittedDocumentTypes . Read ( cache . Get ( D ) ! , "Name" ) , Is . EqualTo ( "appeared" ) ) ;
RefusedOnDisk ( Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( Canary ) , D ) ) ) ! , D ) ;
Assert . That ( cache . IsDirty , Is . False ) ;
}
2026-10-03 13:23:58 +02:00
// A refusal reads the record it refused: in a store holding a variant, where a write lands only onto the store as this cache
// last read it, the next write of another record lands at once instead of being refused for the record already refused.
[Test]
public void AfterAnUndeclaredRefusalTheNextWriteIntoAVariantStoreLands ( [ Values ( Write . Flush , Write . Commit ) ] Write write )
{
var path = PathOf ( "variant-store.cc" ) ;
using var cache = Open ( path , DeckOnly , directory : false ) ;
cache . Commit ( batch = >
{
batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ;
batch . Upsert ( Deck , DeckOf ( "e" ) , E ) ;
batch . UpsertVariant ( V , E , new [ ] { cache . Override ( Deck , "Name" , "v" ) } ) ;
} ) ;
Move ( path , D , "moved" , directory : false ) ;
RefusedOnDisk ( Assert . Throws < CultSchemaConflictException > ( ( ) = > Land ( cache , write , D , DeckOf ( Canary ) ) ) ! , D ) ;
Land ( cache , write , new CultRecordKey ( "f" ) , DeckOf ( "f" ) ) ;
Assert . That ( Read ( path ) . Records . Select ( record = > record . Key ) , Is . EquivalentTo ( new [ ] { "d" , "e" , "f" , "v" } ) ) ;
Assert . That ( Read ( path ) . Records . Single ( record = > record . Key = = D . Value ) . SchemaId , Is . EqualTo ( MovingId ) ) ;
}
2026-10-03 13:11:38 +02:00
public enum DirectWrite
{
Push ,
Delete ,
CommitUpsert ,
CommitDelete
}
// The store's own write API reaches no staging refusal: a read-only record written through it directly is refused where the
// write lands, in both store kinds, and the store keeps the other writer's record under its id.
[Test]
public void ADirectStoreWriteOfAReadOnlyRecordIsRefusedAndNeverRelabelsIt ( [ Values ] bool directory , [ Values ] DirectWrite write )
{
var path = PathOf ( "direct.cc" ) ;
Move ( path , D , "theirs" , directory ) ;
var before = Fingerprint ( path ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
var store = StoreOf ( cache ) ;
var mine = new CultStoredDocument ( D , DateTimeOffset . UtcNow . ToString ( "O" ) , DeckOnly . GetRequired ( Deck ) , DeckOf ( Canary ) ) ;
var held = cache . GetStored ( D ) ! ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = >
{
switch ( write )
{
case DirectWrite . Push :
store . Push ( mine ) ;
store . PushAll ( ) ;
break ;
case DirectWrite . Delete :
store . Delete ( held ) ;
store . PushAll ( ) ;
break ;
case DirectWrite . CommitUpsert :
2026-10-12 04:03:41 +02:00
store . CommitBatch ( new CultCommitRequest ( new [ ] { mine } , Array . Empty < CultStoredDocument > ( ) , Array . Empty < string > ( ) , Array . Empty < ( CultRecordKey , string? ) > ( ) , false ) , wait : true ) ;
2026-10-03 13:11:38 +02:00
break ;
case DirectWrite . CommitDelete :
2026-10-12 04:03:41 +02:00
store . CommitBatch ( new CultCommitRequest ( Array . Empty < CultStoredDocument > ( ) , new [ ] { held } , Array . Empty < string > ( ) , Array . Empty < ( CultRecordKey , string? ) > ( ) , false ) , wait : true ) ;
2026-10-03 13:11:38 +02:00
break ;
}
} ) ! ;
RefusedOnDisk ( refusal , D ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
Assert . That ( Read ( path ) . Records . Single ( ) . SchemaId , Is . EqualTo ( MovingId ) , "never relabelled" ) ;
Assert . That ( store . IsDirty , Is . False ) ;
Assert . That ( EmittedDocumentTypes . Read ( cache . Get ( D ) ! , "Name" ) , Is . EqualTo ( "theirs" ) ) ;
}
2026-10-12 04:03:41 +02:00
// A removal of a foreign record, which the cache does not hold, is refused typed where the store judges it under its lock: at
// the commit that carries it, or the flush that follows a Remove, which itself costs nothing and returns false. The caller is
// never told a write removed what the store still carries. The store is read at that judgement, so a foreign record another
// writer added after the load is refused too, and listed foreign from then on.
2026-10-03 13:11:38 +02:00
[Test]
2026-10-03 15:13:32 +02:00
public void RemovingAForeignRecordIsRefusedNotReportedAsDone ( [ Values ] bool directory , [ Values ] bool batch , [ Values ] bool appearedAfterLoad )
2026-10-03 13:11:38 +02:00
{
2026-10-03 15:13:32 +02:00
var path = PathOf ( directory ? "remove-foreign-dir.cc" : "remove-foreign-file.cc" ) ;
using ( var seed = Open ( path , Full , directory ) )
{
seed . Commit ( stage = > stage . Upsert ( Deck , DeckOf ( "d" ) , D ) ) ;
if ( ! appearedAfterLoad )
seed . Commit ( stage = > stage . Upsert ( Widget , WidgetOf ( "w" ) , W ) ) ;
}
2026-10-03 13:11:38 +02:00
using ( var cache = Open ( path , DeckOnly , directory ) )
{
2026-10-03 15:13:32 +02:00
if ( appearedAfterLoad )
{
using var other = Open ( path , Full , directory ) ;
other . Commit ( stage = > stage . Upsert ( Widget , WidgetOf ( "w" ) , W ) ) ;
}
var before = Fingerprint ( path ) ;
2026-10-03 13:11:38 +02:00
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = >
{
if ( batch )
cache . Commit ( stage = > stage . Remove ( W ) ) ;
else
2026-10-12 04:03:41 +02:00
{
Assert . That ( cache . Remove ( W ) , Is . False , "a key the cache does not hold costs nothing at the call" ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
}
2026-10-03 13:11:38 +02:00
} ) ! ;
Assert . That ( ( refusal . RecordKey , refusal . SchemaId ) , Is . EqualTo ( ( W . Value , WidgetId ) ) ) ;
Assert . That ( cache . IsDirty , Is . False ) ;
2026-10-03 15:13:32 +02:00
Assert . That ( StoreOf ( cache ) . ForeignRecords . Select ( record = > record . Key ) , Is . EqualTo ( new [ ] { "w" } ) ) ;
2026-10-12 04:09:43 +02:00
Assert . DoesNotThrow ( ( ) = > cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) , "a refusal forgets the check it refused" ) ;
2026-10-03 13:11:38 +02:00
Assert . That ( cache . Remove ( new CultRecordKey ( "absent" ) ) , Is . False , "a key nothing holds is still no removal" ) ;
2026-10-03 15:13:32 +02:00
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
2026-10-03 13:11:38 +02:00
}
}
2026-10-12 04:03:41 +02:00
// A write that removes a key the cache does not hold is judged under the store's lock, after the load: a foreign record another
// writer committed in between refuses the whole commit, typed, and nothing the commit would have written lands.
[Test]
public void AForeignRecordThatLandsBetweenTheLoadAndTheCommitRefusesTheCommitThatRemovesIt ( [ Values ] bool directory )
{
var path = PathOf ( "window.cc" ) ;
using ( var seed = Open ( path , Full , directory ) )
seed . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ) ;
using var a = Open ( path , DeckOnly , directory ) ;
using ( var b = Open ( path , Full , directory ) )
b . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "w" ) , W ) ) ;
var before = Fingerprint ( path ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > a . Commit ( batch = >
{
batch . Upsert ( Deck , DeckOf ( Canary ) , D ) ;
batch . Remove ( W ) ;
} ) ) ! ;
Assert . Multiple ( ( ) = >
{
Assert . That ( ( refusal . RecordKey , refusal . SchemaId ) , Is . EqualTo ( ( W . Value , WidgetId ) ) ) ;
Assert . That ( refusal . Message , Does . Not . Contain ( Canary ) ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) , "nothing was written, d included" ) ;
Assert . That ( a . IsDirty , Is . False ) ;
} ) ;
}
// Removing a key nothing holds reads no store: the judgement is the flush's, so a store that cannot be read costs the removal
// nothing and the flush is where it is read.
[Test]
public void RemovingAnUnheldKeyReadsNothingUntilTheFlush ( [ Values ] bool directory )
{
var path = Seeded ( "unreadable.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
var original = File . ReadAllBytes ( path ) ;
File . WriteAllBytes ( path , new byte [ ] { 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 } ) ;
try
{
Assert . DoesNotThrow ( ( ) = > Assert . That ( cache . Remove ( new CultRecordKey ( "absent" ) ) , Is . False ) ) ;
Assert . Throws < CultStoreUnreadableException > ( ( ) = > cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ) ;
}
finally
{
File . WriteAllBytes ( path , original ) ;
}
}
// A checked key is judged and never removed: another writer's declared record at it stays where it is.
[Test]
public void ACheckedKeyIsNeverRemoved ( [ Values ] bool directory )
{
var path = PathOf ( "checked.cc" ) ;
using ( var seed = Open ( path , Full , directory ) )
seed . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ) ;
2026-10-12 04:09:43 +02:00
using var a = Open ( path , DeckOnly , directory ) ;
2026-10-12 05:06:19 +02:00
a . UpsertAsync ( Deck , DeckOf ( "e" ) , E ) . GetAwaiter ( ) . GetResult ( ) ;
2026-10-12 04:03:41 +02:00
Assert . That ( a . Remove ( W ) , Is . False ) ;
using ( var b = Open ( path , Full , directory ) )
b . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "theirs" ) , W ) ) ;
var theirs = Stored ( path , W ) ;
Assert . DoesNotThrow ( ( ) = > a . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ) ;
2026-10-12 05:06:19 +02:00
Assert . That ( Read ( path ) . Records . Select ( record = > record . Key ) , Is . EquivalentTo ( new [ ] { "d" , "e" , "w" } ) , "the staged write landed beside the checked key" ) ;
2026-10-12 04:03:41 +02:00
Assert . That ( Stored ( path , W ) , Is . EqualTo ( theirs ) , "the record at the checked key is still the other writer's bytes" ) ;
2026-10-12 04:09:43 +02:00
using ( var c = Open ( path , Full , directory ) )
c . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , W ) ) ;
Assert . DoesNotThrow ( ( ) = > a . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) , "a flush clears what it judged: the next one has nothing to judge" ) ;
2026-10-12 04:03:41 +02:00
}
// ExpectUnchanged compares the foreign records a store carries at the versions it read them: it holds right after the load,
// and fails, writing nothing, once another writer changed one.
[Test]
public void ExpectUnchangedHoldsOverAStoreCarryingAForeignRecord ( [ Values ] bool directory )
{
var path = Seeded ( "expect-unchanged.cc" , directory ) ;
using var a = Open ( path , DeckOnly , directory ) ;
Assert . That ( StoreOf ( a ) . ForeignRecords . Select ( record = > record . Key ) , Is . EqualTo ( new [ ] { "w" } ) ) ;
Assert . That ( a . Commit ( batch = >
{
batch . ExpectUnchanged ( ) ;
batch . Upsert ( Deck , DeckOf ( "e" ) , E ) ;
} ) , Is . True , "nothing changed since the load" ) ;
using ( var b = Open ( path , Full , directory ) )
b . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "changed" ) , W ) ) ;
var before = Fingerprint ( path ) ;
Assert . That ( a . Commit ( batch = >
{
batch . ExpectUnchanged ( ) ;
batch . Upsert ( Deck , DeckOf ( "f" ) , new CultRecordKey ( "f" ) ) ;
} ) , Is . False , "another writer changed the foreign record" ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
}
// A foreign key listed at the load that another writer then replaced with a record this cache reads but does not declare: the
// refused removal (judged at the flush) reloads it read-only and takes it off the foreign list.
[Test]
public void AListedForeignKeyBecomesReadOnlyWhenARemovalOfItIsRefused ( [ Values ] bool directory )
{
var path = Seeded ( "delist.cc" , directory ) ;
using var a = Open ( path , DeckOnly , directory ) ;
Assert . That ( StoreOf ( a ) . ForeignRecords . Select ( record = > record . Key ) , Is . EqualTo ( new [ ] { "w" } ) ) ;
2026-10-12 05:06:19 +02:00
// The writer declares the widget's id, so it may replace the widget with a deck record under an id a reads but does not declare.
using ( var other = Open ( path , CultDocumentRegistry . ForTypes ( new [ ] { MovingDeck , Widget } ) , directory ) )
Assert . That ( other . Commit ( batch = > batch . Upsert ( MovingDeck , New ( MovingDeck , ( "Name" , "moved" ) ) , W ) ) , Is . True ) ;
2026-10-12 04:03:41 +02:00
Assert . That ( a . Remove ( W ) , Is . False ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > a . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ) ! ;
RefusedOnDisk ( refusal , W ) ;
Assert . That ( StoreOf ( a ) . ForeignRecords , Is . Empty , "no longer foreign" ) ;
Assert . That ( a . Get ( W ) , Is . Not . Null , "held, read-only" ) ;
Assert . Throws < CultSchemaConflictException > ( ( ) = > a . Remove ( W ) , "refused at the call now: the cache holds it" ) ;
}
// A single-file store holding a variant refuses a write after another writer changed it; a changed key whose record this cache
// does not read is listed as foreign by that refusal, as a load lists it.
[Test]
public void AVariantStoreRefusalListsTheRecordItRefusedAsForeign ( [ Values ( Write . Flush , Write . Commit ) ] Write write )
{
var path = PathOf ( "variant-refusal.cc" ) ;
var gears = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( VariantTests . VariantGear ) } ) ;
var withWidget = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( VariantTests . VariantGear ) , Widget } ) ;
using var a = Open ( path , gears , directory : false ) ;
var baseKey = new CultRecordKey ( "laser" ) ;
a . Commit ( batch = >
{
batch . Upsert ( typeof ( VariantTests . VariantGear ) , new VariantTests . VariantGear { Name = "laser" , Power = 10 , Code = "l1" } , baseKey ) ;
batch . UpsertVariant ( new CultRecordKey ( "laser-big" ) , baseKey , new [ ]
{
a . Override < VariantTests . VariantGear > ( nameof ( VariantTests . VariantGear . Name ) , "laser big" ) ,
a . Override < VariantTests . VariantGear > ( nameof ( VariantTests . VariantGear . Power ) , 25 ) ,
a . Override < VariantTests . VariantGear > ( nameof ( VariantTests . VariantGear . Code ) , "laser big" )
} ) ;
} ) ;
using ( var b = Open ( path , withWidget , directory : false ) )
b . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "w" ) , W ) ) ;
Assert . That ( StoreOf ( a ) . ForeignRecords , Is . Empty ) ;
var conflict = Assert . Throws < CultWriteConflictException > ( ( ) = >
{
var next = new VariantTests . VariantGear { Name = "next" , Power = 1 , Code = "n" } ;
var key = new CultRecordKey ( "n" ) ;
if ( write = = Write . Flush )
{
a . UpsertAsync ( typeof ( VariantTests . VariantGear ) , next , key ) . GetAwaiter ( ) . GetResult ( ) ;
a . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
}
else
{
a . Commit ( batch = > batch . Upsert ( typeof ( VariantTests . VariantGear ) , next , key ) ) ;
}
} ) ! ;
Assert . That ( conflict . ChangedKeys , Does . Contain ( W . Value ) ) ;
var listed = StoreOf ( a ) . ForeignRecords . Single ( ) ;
Assert . That ( ( listed . Key , listed . SchemaId ) , Is . EqualTo ( ( W . Value , WidgetId ) ) , "the refused record is listed as the store holds it" ) ;
Assert . That ( a . Get ( W ) , Is . Null ) ;
}
2026-10-12 07:36:45 +02:00
private static CacheBackingStore StoreAt ( string path , bool directory , bool readOnly ) = > directory
? new DirectoryMessagePackBackingStore ( path , readOnly : readOnly )
: new SingleFileMessagePackBackingStore ( path , readOnly ) ;
// A batch with nothing to write and no key to check commits through no store: a staged write waits for its own flush.
[Test]
public void ABatchWithNothingToWriteOrCheckCommitsNothingToTheStore ( [ Values ] bool directory )
{
var path = Seeded ( "empty-batch.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
cache . UpsertAsync ( Deck , DeckOf ( "e" ) , E ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( cache . Commit ( batch = > { } ) , Is . True ) ;
Assert . That ( Read ( path ) . Records . Select ( record = > record . Key ) , Is . EquivalentTo ( new [ ] { "d" , "w" } ) , "e is still only staged" ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( Read ( path ) . Records . Select ( record = > record . Key ) , Is . EquivalentTo ( new [ ] { "d" , "e" , "w" } ) ) ;
}
// A write that only checked keys has nothing to write: no file is replaced and no generation is written, by a commit or a flush.
[Test]
public void AWriteThatOnlyCheckedKeysWritesNoFile ( [ Values ] bool directory , [ Values ] bool viaCommit )
{
var path = Seeded ( "check-only.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
var past = new DateTime ( 2001 , 1 , 1 , 0 , 0 , 0 , DateTimeKind . Utc ) ;
File . SetLastWriteTimeUtc ( path , past ) ;
var before = Fingerprint ( path ) ;
if ( viaCommit )
Assert . That ( cache . Commit ( batch = > batch . Remove ( new CultRecordKey ( "absent" ) ) ) , Is . True ) ;
else
{
Assert . That ( cache . Remove ( new CultRecordKey ( "absent" ) ) , Is . False ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
}
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
Assert . That ( File . GetLastWriteTimeUtc ( path ) , Is . EqualTo ( past ) , "the store was not replaced" ) ;
Assert . That ( cache . IsDirty , Is . False ) ;
}
// A flush or commit that only checked keys clears every check it judged: the next one has nothing to judge, so it does not
// refuse a record another writer put at a checked key afterwards.
[Test]
public void AWriteThatOnlyCheckedKeysClearsTheChecksItJudged ( [ Values ] bool directory , [ Values ] bool viaCommit )
{
var path = Seeded ( "check-cleared.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
var staged = new CultRecordKey ( "staged" ) ;
Assert . That ( cache . Remove ( staged ) , Is . False ) ;
if ( viaCommit )
Assert . That ( cache . Commit ( batch = > batch . Remove ( new CultRecordKey ( "requested" ) ) ) , Is . True ) ;
else
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "now a widget" ) , staged ) ) ;
Assert . DoesNotThrow ( ( ) = > cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ) ;
}
// A disposed cache and a disposed store judge the checks a removal staged, as their flush does.
[Test]
public void DisposingAStoreJudgesTheChecksARemovalStaged ( [ Values ] bool directory , [ Values ] bool viaCache )
{
var path = Seeded ( "dispose.cc" , directory ) ;
var cache = Open ( path , DeckOnly , directory ) ;
cache . FlushAttachedStoresOnDispose = true ;
StoreOf ( cache ) . FlushOnDispose = true ;
Assert . That ( cache . Remove ( W ) , Is . False ) ;
var before = Fingerprint ( path ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = >
{
if ( viaCache )
cache . Dispose ( ) ;
else
StoreOf ( cache ) . Dispose ( ) ;
} ) ! ;
Assert . That ( ( refusal . RecordKey , refusal . SchemaId ) , Is . EqualTo ( ( W . Value , WidgetId ) ) ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
try { cache . Dispose ( ) ; } catch ( CultSchemaConflictException ) { }
}
// A read-only store has no lock to judge under, so it answers a removal of a key the cache does not hold at the call, from
// what it last loaded.
[Test]
public void AReadOnlyStoreRefusesTheRemovalOfAForeignRecordAtTheCall ( [ Values ] bool directory )
{
var path = Seeded ( "read-only.cc" , directory ) ;
using var cache = CultCacheMessagePack . Create ( path , new CultCacheOpenOptions { Registry = DeckOnly , UseDirectoryStore = directory , ReadOnly = true } ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . Remove ( W ) ) ! ;
Assert . That ( ( refusal . RecordKey , refusal . SchemaId ) , Is . EqualTo ( ( W . Value , WidgetId ) ) ) ;
Assert . That ( cache . Remove ( new CultRecordKey ( "absent" ) ) , Is . False ) ;
}
2026-10-14 02:07:20 +02:00
// A batch that only checks keys commits through the one writable store; a read-only store answers the checks from its last load.
2026-10-12 07:36:45 +02:00
[Test]
2026-10-14 02:07:20 +02:00
public void AReadOnlyStoreAnswersTheChecksFromItsView ( [ Values ] bool directory )
2026-10-12 07:36:45 +02:00
{
var writable = PathOf ( "home.cc" ) ;
using ( var seed = Open ( writable , Full , directory ) )
seed . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ) ;
var readOnly = PathOf ( "other.cc" ) ;
2026-10-12 07:39:32 +02:00
using ( var seed = Open ( readOnly , CultDocumentRegistry . ForTypes ( new [ ] { Gizmo , Widget } ) , directory ) )
2026-10-12 07:36:45 +02:00
seed . Commit ( batch = >
{
2026-10-12 07:39:32 +02:00
batch . Upsert ( Gizmo , New ( Gizmo , ( "Tag" , "g" ) ) , new CultRecordKey ( "g" ) ) ;
2026-10-12 07:36:45 +02:00
batch . Upsert ( Widget , WidgetOf ( "w" ) , W ) ;
} ) ;
2026-10-12 07:39:32 +02:00
using var cache = new CultCache ( CultDocumentRegistry . ForTypes ( new [ ] { Deck , Gizmo } ) ) ;
cache . AddBackingStore ( StoreAt ( writable , directory , readOnly : false ) , Deck ) ;
cache . AddBackingStore ( StoreAt ( readOnly , directory , readOnly : true ) , Gizmo ) ;
2026-10-12 07:36:45 +02:00
var before = Fingerprint ( writable ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . Commit ( batch = > batch . Remove ( W ) ) ) ! ;
Assert . That ( ( refusal . RecordKey , refusal . SchemaId ) , Is . EqualTo ( ( W . Value , WidgetId ) ) ) ;
Assert . That ( Fingerprint ( writable ) , Is . EqualTo ( before ) ) ;
Assert . That ( cache . Commit ( batch = > batch . Remove ( new CultRecordKey ( "absent" ) ) ) , Is . True , "a key no store holds is free" ) ;
}
// The home store judges a check under its lock, from the record it holds then: a foreign record it listed at the load that
// another writer since replaced with a declared one no longer refuses the commit, and is not removed.
[Test]
public void TheHomeStoreJudgesACheckUnderItsLockNotFromItsLoad ( [ Values ] bool directory )
{
var path = Seeded ( "home-judges.cc" , directory ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
Assert . That ( StoreOf ( cache ) . ForeignRecords . Select ( record = > record . Key ) , Is . EqualTo ( new [ ] { "w" } ) ) ;
using ( var other = Open ( path , Full , directory ) )
other . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "theirs" ) , W ) ) ;
var theirs = Stored ( path , W ) ;
Assert . That ( cache . Commit ( batch = > batch . Remove ( W ) ) , Is . True ) ;
Assert . That ( Stored ( path , W ) , Is . EqualTo ( theirs ) , "the declared record the checked key now holds is not removed" ) ;
}
2026-10-12 07:46:10 +02:00
// A cache disposed with several stores flushes when any one of them has checks staged, not only when all do (a read-only store
// never has any).
[Test]
public void DisposingACacheFlushesWhenOnlyOneStoreHasChecksStaged ( [ Values ] bool directory )
{
var writable = Seeded ( "dispose-home.cc" , directory ) ;
var other = PathOf ( "dispose-other.cc" ) ;
using ( var seed = Open ( other , CultDocumentRegistry . ForTypes ( new [ ] { Gizmo } ) , directory ) )
seed . Commit ( batch = > batch . Upsert ( Gizmo , New ( Gizmo , ( "Tag" , "g" ) ) , new CultRecordKey ( "g" ) ) ) ;
var cache = new CultCache ( CultDocumentRegistry . ForTypes ( new [ ] { Deck , Gizmo } ) ) { FlushAttachedStoresOnDispose = true } ;
cache . AddBackingStore ( StoreAt ( writable , directory , readOnly : false ) , Deck ) ;
cache . AddBackingStore ( StoreAt ( other , directory , readOnly : true ) , Gizmo ) ;
Assert . That ( cache . Remove ( W ) , Is . False ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . Dispose ( ) ) ! ;
Assert . That ( ( refusal . RecordKey , refusal . SchemaId ) , Is . EqualTo ( ( W . Value , WidgetId ) ) ) ;
try { cache . Dispose ( ) ; } catch ( CultSchemaConflictException ) { }
}
2026-10-14 02:07:20 +02:00
// Two writable stores: home.cc holds a deck and takes Deck, other.cc holds a gizmo and takes Gizmo, and, when asked, a widget at
// w that no registered type declares, so the cache lists it foreign at the load.
private ( CultCache Cache , string Home , string Other ) TwoWritableStores ( bool directory , bool otherListsWidget )
{
var home = PathOf ( "home.cc" ) ;
using ( var seed = Open ( home , DeckOnly , directory ) )
seed . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ) ;
var other = PathOf ( "other.cc" ) ;
using ( var seed = Open ( other , CultDocumentRegistry . ForTypes ( new [ ] { Gizmo , Widget } ) , directory ) )
{
seed . Commit ( batch = > batch . Upsert ( Gizmo , New ( Gizmo , ( "Tag" , "g" ) ) , new CultRecordKey ( "g" ) ) ) ;
if ( otherListsWidget )
seed . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "w" ) , W ) ) ;
}
var cache = new CultCache ( CultDocumentRegistry . ForTypes ( new [ ] { Deck , Gizmo } ) ) ;
cache . AddBackingStore ( StoreAt ( home , directory , readOnly : false ) , Deck ) ;
cache . AddBackingStore ( StoreAt ( other , directory , readOnly : false ) , Gizmo ) ;
return ( cache , home , other ) ;
}
// Every writable store judges a batch's checks under its own lock, whichever store the batch commits through: a record another
// writer put at a checked key in a store the batch does not write, after the load, refuses the whole commit and writes nothing.
[Test]
public void EveryWritableStoreRefusesACommitThatRemovesItsLateForeignRecord ( [ Values ] bool directory , [ Values ] bool withWrite )
{
var ( cache , home , other ) = TwoWritableStores ( directory , otherListsWidget : false ) ;
using var _ = cache ;
using ( var late = Open ( other , CultDocumentRegistry . ForTypes ( new [ ] { Gizmo , Widget } ) , directory ) )
late . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "late" ) , W ) ) ;
var homeBefore = Fingerprint ( home ) ;
var otherBefore = Fingerprint ( other ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . Commit ( batch = >
{
if ( withWrite )
batch . Upsert ( Deck , DeckOf ( "d2" ) , new CultRecordKey ( "d2" ) ) ;
batch . Remove ( W ) ;
} ) ) ! ;
Assert . That ( ( refusal . RecordKey , refusal . SchemaId ) , Is . EqualTo ( ( W . Value , WidgetId ) ) ) ;
Assert . That ( Fingerprint ( home ) , Is . EqualTo ( homeBefore ) ) ;
Assert . That ( Fingerprint ( other ) , Is . EqualTo ( otherBefore ) ) ;
Assert . That ( Read ( home ) . Records . Select ( record = > record . Key ) , Does . Not . Contain ( "d2" ) ) ;
Assert . That ( cache . BackingStores [ 1 ] . ForeignRecords . Select ( record = > record . Key ) , Is . EqualTo ( new [ ] { "w" } ) ) ;
}
// The non-home judgement reads the store under its lock, not its listing: a foreign record that store listed at the load,
// since replaced by a declared one, no longer refuses the commit and is not removed.
[Test]
public void ANonHomeStoreJudgesACheckUnderItsLockNotFromItsLoad ( [ Values ] bool directory )
{
var ( cache , home , other ) = TwoWritableStores ( directory , otherListsWidget : true ) ;
using var _ = cache ;
Assert . That ( cache . BackingStores [ 1 ] . ForeignRecords . Select ( record = > record . Key ) , Is . EqualTo ( new [ ] { "w" } ) ) ;
using ( var late = Open ( other , CultDocumentRegistry . ForTypes ( new [ ] { Gizmo , Widget } ) , directory ) )
late . Commit ( batch = > batch . Upsert ( Gizmo , New ( Gizmo , ( "Tag" , "theirs" ) ) , W ) ) ;
var theirs = Stored ( other , W ) ;
Assert . That ( cache . Commit ( batch = >
{
batch . Upsert ( Deck , DeckOf ( "d2" ) , new CultRecordKey ( "d2" ) ) ;
batch . Remove ( W ) ;
} ) , Is . True ) ;
Assert . That ( Read ( home ) . Records . Select ( record = > record . Key ) , Does . Contain ( "d2" ) ) ;
Assert . That ( Stored ( other , W ) , Is . EqualTo ( theirs ) , "the declared record the checked key now holds is not removed" ) ;
}
// A store that is not the batch's home and cannot take its lock makes the commit contended before the home writes.
[Test]
public void ANonHomeStoreHeldByAnotherWriterMakesTryCommitContended ( )
{
var ( cache , home , other ) = TwoWritableStores ( directory : false , otherListsWidget : false ) ;
using var _ = cache ;
var homeBefore = Fingerprint ( home ) ;
using ( new FileStream ( other + ".lock" , FileMode . OpenOrCreate , FileAccess . ReadWrite , FileShare . None ) )
{
var outcome = cache . TryCommit ( batch = >
{
batch . Upsert ( Deck , DeckOf ( "d2" ) , new CultRecordKey ( "d2" ) ) ;
batch . Remove ( W ) ;
} ) ;
Assert . That ( outcome , Is . EqualTo ( CultCommitOutcome . Contended ) ) ;
}
Assert . That ( Fingerprint ( home ) , Is . EqualTo ( homeBefore ) ) ;
}
2026-10-14 03:14:46 +02:00
private static void CopyStoreOver ( string from , string to , bool directory )
{
if ( directory )
{
var source = DirectoryMessagePackBackingStore . DefaultRecordDirectoryPath ( from ) ;
var target = DirectoryMessagePackBackingStore . DefaultRecordDirectoryPath ( to ) ;
Directory . CreateDirectory ( target ) ;
foreach ( var file in Directory . GetFiles ( source , "*.msgpack" ) )
File . Copy ( file , Path . Combine ( target , Path . GetFileName ( file ) ) , true ) ;
}
File . Copy ( from , to , true ) ;
}
private static FileStream HoldStoreLock ( string path , bool directory ) = > directory
? new FileStream ( Path . Combine ( DirectoryMessagePackBackingStore . DefaultRecordDirectoryPath ( path ) , ".commit.lock" ) , FileMode . OpenOrCreate , FileAccess . ReadWrite , FileShare . None )
: new FileStream ( path + ".lock" , FileMode . OpenOrCreate , FileAccess . ReadWrite , FileShare . None ) ;
// A non-home store is judged only after the commit holds that store's own lock: a foreign record another writer lands at the
// checked key while the commit waits for the lock is refused, whichever kind the store is and whether or not the batch writes.
[Test]
public void ANonHomeStoreIsReadOnlyAfterItsLockIsTaken ( [ Values ] bool directory , [ Values ] bool withWrite )
{
var ( cache , home , other ) = TwoWritableStores ( directory , otherListsWidget : false ) ;
using var _ = cache ;
var copy = PathOf ( "copy.cc" ) ;
CopyStoreOver ( other , copy , directory ) ;
using ( var late = Open ( copy , CultDocumentRegistry . ForTypes ( new [ ] { Gizmo , Widget } ) , directory ) )
late . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "late" ) , W ) ) ;
var homeBefore = Fingerprint ( home ) ;
Exception ? thrown = null ;
var held = HoldStoreLock ( other , directory ) ;
var worker = new System . Threading . Thread ( ( ) = >
{
try
{
cache . Commit ( batch = >
{
if ( withWrite )
batch . Upsert ( Deck , DeckOf ( "d2" ) , new CultRecordKey ( "d2" ) ) ;
batch . Remove ( W ) ;
} ) ;
}
catch ( Exception ex ) { thrown = ex ; }
} ) ;
worker . Start ( ) ;
// The lock wait is a Thread.Sleep retry loop, so the worker sleeping is the worker waiting for the held lock.
Assert . That ( System . Threading . SpinWait . SpinUntil (
( ) = > ( worker . ThreadState & System . Threading . ThreadState . WaitSleepJoin ) ! = 0 , TimeSpan . FromSeconds ( 10 ) ) , Is . True , "the commit waits on the held lock" ) ;
CopyStoreOver ( copy , other , directory ) ;
var otherAfterSwap = Fingerprint ( other ) ;
held . Dispose ( ) ;
Assert . That ( worker . Join ( TimeSpan . FromSeconds ( 40 ) ) , Is . True , "the commit finished" ) ;
Assert . That ( thrown , Is . TypeOf < CultSchemaConflictException > ( ) ) ;
Assert . That ( Fingerprint ( home ) , Is . EqualTo ( homeBefore ) , "the home is unwritten" ) ;
Assert . That ( Fingerprint ( other ) , Is . EqualTo ( otherAfterSwap ) , "the non-home store is unwritten" ) ;
}
// A directory store judging a check honours wait=false: its lease held by another writer makes TryCommit contended at once.
[Test]
public void ADirectoryNonHomeStoreWhoseLeaseIsHeldMakesTryCommitContended ( )
{
var ( cache , home , other ) = TwoWritableStores ( directory : true , otherListsWidget : false ) ;
using var _ = cache ;
var homeBefore = Fingerprint ( home ) ;
CultCommitOutcome outcome ;
using ( HoldStoreLock ( other , directory : true ) )
outcome = cache . TryCommit ( batch = >
{
batch . Upsert ( Deck , DeckOf ( "d2" ) , new CultRecordKey ( "d2" ) ) ;
batch . Remove ( W ) ;
} ) ;
Assert . That ( outcome , Is . EqualTo ( CultCommitOutcome . Contended ) ) ;
Assert . That ( Fingerprint ( home ) , Is . EqualTo ( homeBefore ) ) ;
}
2026-10-14 02:23:14 +02:00
// A non-home store the cache attached before anything was written to it has no directory and no lease yet: judging a check
// there creates what it locks, finds nothing, and writes no store.
[Test]
public void ANonHomeStoreNothingWasEverWrittenToJudgesAChecksOnlyCommit ( [ Values ] bool directory )
{
var home = PathOf ( "home.cc" ) ;
using ( var seed = Open ( home , DeckOnly , directory ) )
seed . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ) ;
var other = PathOf ( "fresh" + Path . DirectorySeparatorChar + "other.cc" ) ;
using var cache = new CultCache ( CultDocumentRegistry . ForTypes ( new [ ] { Deck , Gizmo } ) ) ;
cache . AddBackingStore ( StoreAt ( home , directory , readOnly : false ) , Deck ) ;
cache . AddBackingStore ( StoreAt ( other , directory , readOnly : false ) , Gizmo ) ;
Assert . That ( cache . Commit ( batch = >
{
batch . Upsert ( Deck , DeckOf ( "d2" ) , new CultRecordKey ( "d2" ) ) ;
batch . Remove ( W ) ;
} ) , Is . True ) ;
Assert . That ( Read ( home ) . Records . Select ( record = > record . Key ) , Does . Contain ( "d2" ) ) ;
Assert . That ( File . Exists ( other ) , Is . False , "judging a check writes no store" ) ;
}
// A batch with no check asks no other store anything: a lock another writer holds on a store the batch does not write does
// not stand in its way.
[Test]
public void ABatchWithoutChecksIsNotHeldUpByAnotherStoreLock ( )
{
var ( cache , home , other ) = TwoWritableStores ( directory : false , otherListsWidget : false ) ;
using var _ = cache ;
using ( new FileStream ( other + ".lock" , FileMode . OpenOrCreate , FileAccess . ReadWrite , FileShare . None ) )
Assert . That ( cache . TryCommit ( batch = > batch . Upsert ( Deck , DeckOf ( "d2" ) , new CultRecordKey ( "d2" ) ) ) , Is . EqualTo ( CultCommitOutcome . Committed ) ) ;
Assert . That ( Read ( home ) . Records . Select ( record = > record . Key ) , Does . Contain ( "d2" ) ) ;
}
2026-10-14 02:07:20 +02:00
// A foreign record a refusal lists carries the version the refusal read, so a conditional commit over the stores that
// list it holds when nothing has changed since.
[Test]
public void ExpectUnchangedHoldsAfterARefusalListedTheForeignRecord ( [ Values ] bool directory )
{
var path = PathOf ( "refusal-listed.cc" ) ;
using ( var seed = Open ( path , Full , directory ) )
seed . Commit ( batch = > batch . Upsert ( Deck , DeckOf ( "d" ) , D ) ) ;
using var cache = Open ( path , DeckOnly , directory ) ;
using ( var late = Open ( path , Full , directory ) )
late . Commit ( batch = > batch . Upsert ( Widget , WidgetOf ( "late" ) , W ) ) ;
Assert . That ( cache . Remove ( W ) , Is . False ) ;
Assert . Throws < CultSchemaConflictException > ( ( ) = > cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ) ;
Assert . That ( StoreOf ( cache ) . ForeignRecords . Select ( record = > record . Key ) , Is . EqualTo ( new [ ] { "w" } ) ) ;
Assert . That ( cache . Commit ( batch = >
{
batch . ExpectUnchanged ( ) ;
batch . Upsert ( Deck , DeckOf ( "d2" ) , new CultRecordKey ( "d2" ) ) ;
} ) , Is . True ) ;
}
C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- The registry's claim decision (Claimant) is split out of resolution. It
never decodes or compares shapes, so a store classifies a record from its
header. The last branch, "No local CultCache schema matches", now means
foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
file holds now, with its catalog entry, and keeps a marked header while it
carries one (it cannot see their ids). A staged write, a commit (onto the
file or whole view) and a whole view over a key the file holds as foreign
refuse with CultSchemaConflictException naming the key and id. A merge
judgement never decodes a foreign record, and one that replaced a held
record takes it away.
- Directory store: a foreign record's page is never read; a generation that
would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
candidate"; the persistence format says what a foreign record is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
}
}