2026-05-31 17:32:42 +01:00
using System ;
using System.Collections.Concurrent ;
using System.Collections.Generic ;
2026-07-20 02:31:10 +01:00
using System.Diagnostics ;
2026-05-31 17:32:42 +01:00
using System.IO ;
using System.Linq ;
2026-09-14 14:51:37 +02:00
using System.Runtime.ExceptionServices ;
2026-05-31 17:32:42 +01:00
using System.Security.Cryptography ;
2026-07-02 21:00:36 +01:00
using System.Threading ;
2026-07-20 00:46:38 +01:00
using System.Threading.Tasks ;
2026-06-27 14:34:53 +01:00
using MessagePack ;
2026-05-31 17:32:42 +01:00
namespace GameCult.Caching.MessagePack ;
2026-09-13 17:17:14 +02:00
// A small hot manifest indexes the store; each record lives in one cold content-addressed page.
2026-05-31 17:32:42 +01:00
public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
{
2026-09-29 21:54:01 +02:00
// The two manifest headers; the shared header decision names them.
private static readonly string IndexedFormatVersion = HeaderFor ( holdsIds : false , existingHeader : null , wholeStore : true , directoryStore : true ) ;
private static readonly string IndexedFormatVersionWithIds = HeaderFor ( holdsIds : true , existingHeader : null , wholeStore : true , directoryStore : true ) ;
2026-09-14 14:16:41 +02:00
// An unleased load that has not settled after this many attempts throws.
private const int UnleasedLoadAttempts = 5 ;
2026-05-31 17:32:42 +01:00
private readonly FileInfo _manifestFile ;
private readonly DirectoryInfo _recordDirectory ;
private readonly ConcurrentDictionary < string , bool > _dirtyKeys = new ( StringComparer . Ordinal ) ;
private readonly ConcurrentDictionary < string , bool > _deletedKeys = new ( StringComparer . Ordinal ) ;
2026-07-20 01:48:56 +01:00
private readonly HashSet < string > _hydratedKeys = new ( StringComparer . Ordinal ) ;
2026-08-19 00:56:37 +02:00
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
public DirectoryMessagePackBackingStore ( string manifestPath , string? recordDirectoryPath = null , bool readOnly = false )
: base ( readOnly )
2026-05-31 17:32:42 +01:00
{
if ( string . IsNullOrWhiteSpace ( manifestPath ) )
{
throw new ArgumentException ( "Manifest path must be non-empty." , nameof ( manifestPath ) ) ;
}
_manifestFile = new FileInfo ( manifestPath ) ;
_recordDirectory = new DirectoryInfo ( recordDirectoryPath ? ? DefaultRecordDirectoryPath ( manifestPath ) ) ;
}
public static string DefaultRecordDirectoryPath ( string manifestPath ) = > manifestPath + ".records" ;
2026-09-14 14:16:41 +02:00
// Test seam: runs between an unleased load's manifest read and its page reads.
internal Action ? UnleasedManifestRead { get ; set ; }
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
public override string ToString ( ) = > _manifestFile . FullName ;
2026-09-13 19:46:18 +02:00
public override void PullAll ( ) = > Held ( PullAllCore ) ;
2026-08-19 00:11:20 +02:00
private void PullAllCore ( )
2026-05-31 17:32:42 +01:00
{
2026-07-20 02:31:10 +01:00
var traceStartup = string . Equals (
Environment . GetEnvironmentVariable ( "CULTCACHE_TRACE_STARTUP_PHASES" ) ,
"1" ,
StringComparison . Ordinal ) ;
var startupPhase = Stopwatch . StartNew ( ) ;
void Trace ( string phase )
{
if ( traceStartup )
Console . WriteLine ( $"CultCache directory-pull phase {phase} took {startupPhase.Elapsed.TotalMilliseconds:0.###}ms." ) ;
startupPhase . Restart ( ) ;
}
2026-05-31 17:32:42 +01:00
var reports = new List < CultSchemaMigrationReport > ( ) ;
2026-07-18 18:35:39 +01:00
var loaded = new Dictionary < string , CultStoredDocument > ( StringComparer . Ordinal ) ;
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 foreign = new List < CultForeignRecord > ( ) ;
2026-09-14 14:16:41 +02:00
for ( var attempt = 1 ; ; attempt + + )
2026-08-19 00:56:37 +02:00
{
2026-09-14 14:16:41 +02:00
loaded . Clear ( ) ;
reports . Clear ( ) ;
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
foreign . Clear ( ) ;
2026-09-14 14:16:41 +02:00
// Only pages named by the manifest read under the lease are loaded; orphaned pages are never loaded. With no
// lock to lease (none created yet, or a store copied without it) a writer may commit mid-load, so an unleased
2026-09-14 14:33:43 +02:00
// load stands only if its manifest is still current; if the manifest moved it reloads, under the lease the
// writer created. A page that fails under an unmoved manifest is corruption, not a race.
2026-09-14 14:16:41 +02:00
using var lease = AcquireCommitLease ( wait : true , create : false ) ;
var manifestBytes = ReadManifestBytes ( ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var ( manifest , _ ) = ParseManifest ( manifestBytes ) ;
2026-08-19 00:56:37 +02:00
Trace ( $"manifest records={manifest.Records.Length}" ) ;
2026-09-14 14:16:41 +02:00
try
{
if ( lease = = null )
UnleasedManifestRead ? . Invoke ( ) ;
LoadRecordPages (
manifest . Records . OrderBy ( record = > record . Key , StringComparer . Ordinal ) . ToArray ( ) ,
manifest . SchemaCatalog ,
loaded ,
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
reports ,
foreign ) ;
2026-09-14 14:51:37 +02:00
if ( lease ! = null | | ! ManifestMoved ( manifestBytes ) )
2026-09-14 14:16:41 +02:00
{
Trace ( $"indexed-pages loaded={loaded.Count}" ) ;
break ;
}
}
2026-09-14 14:51:37 +02:00
catch ( Exception exception ) when ( lease = = null & & IsTornGeneration ( exception ) )
2026-09-14 14:16:41 +02:00
{
2026-09-14 14:51:37 +02:00
// The re-read stays out of the filter: an exception thrown inside a filter is swallowed as false.
if ( ! ManifestMoved ( manifestBytes ) )
throw ;
2026-09-14 14:16:41 +02:00
if ( attempt = = UnleasedLoadAttempts )
throw Unsettled ( exception ) ;
continue ;
}
if ( attempt = = UnleasedLoadAttempts )
throw Unsettled ( null ) ;
2026-05-31 17:32:42 +01:00
}
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
bool Staged ( string key ) = > _dirtyKeys . ContainsKey ( key ) | | _deletedKeys . ContainsKey ( key ) ;
var arrived = loaded
. Where ( pair = > ! Staged ( pair . Key ) & &
( ! Entries . TryGetValue ( pair . Key , out var existing ) | |
CultCache: a record's version is the SHA-256 of its stored bytes; conditions, pulls, the staleness check and a refusal's reload compare it, never storedAt
Adds CultStoredDocument.StoredVersion, CacheBackingStore.VersionOf and the
SingleFileBackingStore.SerializeRecord hook, and swaps every identity comparison
in the same commit: SameStoredIdentity stays the one comparison, over versions.
Deletes the Identity helper, the (schemaId, storedAt) Expected tuple, the
in-memory fake-record projection and the directory's storedAt arrival.
Breaking public API for 2.0.0: CultCommitRequest.Expected, ConditionsHold and
the abstract SingleFileBackingStore.SerializeRecord.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:23:31 +02:00
! SameStoredIdentity ( existing . StoredVersion , pair . Value . StoredVersion ) ) )
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
. Select ( pair = > pair . Value )
. ToArray ( ) ;
var departed = _hydratedKeys
. Where ( key = > ! loaded . ContainsKey ( key ) & & ! Staged ( key ) & & Entries . ContainsKey ( key ) )
. Select ( key = > Entries [ key ] )
. ToArray ( ) ;
if ( arrived . Length > 0 | | departed . Length > 0 )
Loaded ? . Invoke ( arrived , departed ) ;
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
// Adopted only once the cache admitted the load; a refused load leaves the store's durable view untouched.
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
foreach ( var stored in departed )
Entries . TryRemove ( stored . Key . Value , out _ ) ;
_hydratedKeys . RemoveWhere ( key = > ! loaded . ContainsKey ( key ) & & ! Staged ( key ) ) ;
foreach ( var stored in arrived )
Entries [ stored . Key . Value ] = stored ;
2026-07-20 01:48:56 +01:00
foreach ( var key in loaded . Keys )
_hydratedKeys . Add ( key ) ;
SetLastSchemaMigrationReports ( reports ) ;
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
SetForeignRecords ( foreign ) ;
2026-07-20 01:48:56 +01:00
IsDirty = ! _dirtyKeys . IsEmpty | | ! _deletedKeys . IsEmpty ;
2026-07-20 02:31:10 +01:00
Trace ( "publish" ) ;
2026-07-20 01:48:56 +01:00
}
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
// Variants wait for the directory store's own cut; until then it refuses one rather than persist a resolved view as a plain page.
private static void RefuseVariant ( CultStoredDocument entry )
{
if ( entry . Variant ! = null )
throw new NotSupportedException (
$"Record '{entry.Key.Value}' is a variant of '{entry.Variant.BaseKey}'; the directory store does not hold document variants yet." ) ;
}
2026-05-31 17:32:42 +01:00
public override void Push ( CultStoredDocument entry )
{
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
ThrowIfReadOnly ( ) ;
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
RefuseVariant ( entry ) ;
2026-09-13 19:46:18 +02:00
Held ( ( ) = >
2026-08-19 00:11:20 +02:00
{
Entries [ entry . Key . Value ] = entry ;
_dirtyKeys [ entry . Key . Value ] = true ;
_deletedKeys . TryRemove ( entry . Key . Value , out _ ) ;
IsDirty = true ;
2026-09-13 19:46:18 +02:00
} ) ;
2026-05-31 17:32:42 +01:00
}
public override void Delete ( CultStoredDocument entry )
{
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
ThrowIfReadOnly ( ) ;
2026-09-13 19:46:18 +02:00
Held ( ( ) = >
2026-08-19 00:11:20 +02:00
{
Entries . TryRemove ( entry . Key . Value , out _ ) ;
_dirtyKeys . TryRemove ( entry . Key . Value , out _ ) ;
_deletedKeys [ entry . Key . Value ] = true ;
IsDirty = true ;
2026-09-13 19:46:18 +02:00
} ) ;
2026-05-31 17:32:42 +01:00
}
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
public override CultCommitOutcome CommitBatch ( CultCommitRequest request , bool wait )
2026-08-19 01:36:23 +02:00
{
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
ThrowIfReadOnly ( ) ;
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
foreach ( var entry in request . Upserts )
RefuseVariant ( entry ) ;
2026-09-13 19:46:18 +02:00
return Held ( ( ) = >
2026-08-19 01:36:23 +02:00
{
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
Directory . CreateDirectory ( _manifestFile . DirectoryName ! ) ;
2026-09-14 13:43:45 +02:00
using var commitLease = AcquireCommitLease ( wait , create : true ) ;
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
if ( commitLease = = null )
return CultCommitOutcome . Contended ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var ( manifest , catalogBytes ) = ReadManifest ( ) ;
CultCache: a record's version is the SHA-256 of its stored bytes; conditions, pulls, the staleness check and a refusal's reload compare it, never storedAt
Adds CultStoredDocument.StoredVersion, CacheBackingStore.VersionOf and the
SingleFileBackingStore.SerializeRecord hook, and swaps every identity comparison
in the same commit: SameStoredIdentity stays the one comparison, over versions.
Deletes the Identity helper, the (schemaId, storedAt) Expected tuple, the
in-memory fake-record projection and the directory's storedAt arrival.
Breaking public API for 2.0.0: CultCommitRequest.Expected, ConditionsHold and
the abstract SingleFileBackingStore.SerializeRecord.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:23:31 +02:00
if ( request . HasConditions & & ! request . ConditionsHold (
2026-10-12 04:03:41 +02:00
manifest . Records . ToDictionary ( record = > record . Key , record = > VersionOf ( record . Payload ) , StringComparer . Ordinal ) , Entries . Values , ForeignRecords ) )
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
return CultCommitOutcome . Mismatch ;
2026-10-12 04:03:41 +02:00
RefuseUndeclaredOnDisk ( manifest , request . Upserts . Concat ( request . Deletes ) . Select ( entry = > entry . Key . Value ) . Concat ( request . Checks ) . Concat ( StagedKeys ) ) ;
if ( request . Upserts . Count = = 0 & & request . Deletes . Count = = 0 & & ! IsDirty )
{
// Nothing to write: the batch only judged keys it does not hold.
MarkFlushSucceeded ( ) ;
return CultCommitOutcome . Committed ;
}
2026-08-19 01:36:23 +02:00
var previousEntries = Entries . ToDictionary ( pair = > pair . Key , pair = > pair . Value , StringComparer . Ordinal ) ;
var previousDirtyKeys = _dirtyKeys . Keys . ToArray ( ) ;
var previousDeletedKeys = _deletedKeys . Keys . ToArray ( ) ;
var wasDirty = IsDirty ;
try
{
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
foreach ( var entry in request . Deletes )
2026-08-19 01:36:23 +02:00
{
Entries . TryRemove ( entry . Key . Value , out _ ) ;
_dirtyKeys . TryRemove ( entry . Key . Value , out _ ) ;
_deletedKeys [ entry . Key . Value ] = true ;
}
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
foreach ( var entry in request . Upserts )
2026-08-19 01:36:23 +02:00
{
Entries [ entry . Key . Value ] = entry ;
_dirtyKeys [ entry . Key . Value ] = true ;
_deletedKeys . TryRemove ( entry . Key . Value , out _ ) ;
}
IsDirty = true ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
WriteGeneration ( manifest , catalogBytes ) ;
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
return CultCommitOutcome . Committed ;
2026-08-19 01:36:23 +02:00
}
catch
{
Entries . Clear ( ) ;
foreach ( var pair in previousEntries )
Entries [ pair . Key ] = pair . Value ;
_dirtyKeys . Clear ( ) ;
foreach ( var key in previousDirtyKeys )
_dirtyKeys [ key ] = true ;
_deletedKeys . Clear ( ) ;
foreach ( var key in previousDeletedKeys )
_deletedKeys [ key ] = true ;
IsDirty = wasDirty ;
throw ;
}
2026-09-13 19:46:18 +02:00
} ) ;
2026-08-19 01:36:23 +02:00
}
Delete CultCache wrappers and unused members
Removes FlushBackingStore, both PrepareForReloadOrShutdown overloads,
PullBackingStoreRecordsAsync, TryGet<T>, TryGetByName, TryGetByIndex,
Resolve<T>, the cache and store Logger, and LastSuccessfulFlushAtUtc on the
cache and stores. GetStoredDocuments<T> folds into GetAll<T>.
The soft flag goes from FlushAllBackingStores, ExecuteTransactionAsync,
CommitBatch and PushAll. It stays on FlushAsync(bool) because
CultCacheStudioWindow reflects on that signature until Cut 6.
CultMesh.FlushAsync and CultNetLocal.FlushAsync keep their parameter and
stop forwarding it.
The store's EntryAdded/EntryUpdated/EntryDeleted subjects become
protected internal Loaded/Unloaded callbacks set by the cache at attach.
AddStoredDocumentInternal is synchronous, so the GetAwaiter().GetResult()
bridges on the store subscriptions and the global materializer are gone.
Tests: drop the LastSuccessfulFlushAtUtc asserts; OpenAsync and GetAll tests
read through Get/GetAll; the persisted-global test proves the durable global
loads without staging a default instead of going through selective hydration;
the lease-before-observers test observes through CultCache.Watch; the
PullSelected selective-hydration test is deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:44:11 +02:00
public override void PushAll ( )
2026-08-19 00:11:20 +02:00
{
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
ThrowIfReadOnly ( ) ;
2026-09-13 19:46:18 +02:00
Held ( ( ) = >
2026-07-20 02:12:22 +01:00
{
2026-10-12 04:03:41 +02:00
if ( ! IsDirty & & StagedChecks . Count = = 0 )
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
return ;
Directory . CreateDirectory ( _manifestFile . DirectoryName ! ) ;
2026-09-14 13:43:45 +02:00
using var commitLease = AcquireCommitLease ( wait : true , create : true ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var ( manifest , catalogBytes ) = ReadManifest ( ) ;
2026-10-03 15:13:32 +02:00
RefuseUndeclaredOnDisk ( manifest , StagedKeys ) ;
2026-10-12 04:03:41 +02:00
if ( IsDirty )
WriteGeneration ( manifest , catalogBytes ) ;
else
MarkFlushSucceeded ( ) ;
2026-09-13 19:46:18 +02:00
} ) ;
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
}
2026-07-20 02:12:22 +01:00
2026-10-12 04:03:41 +02:00
// Every key the next flush or commit judges: the staged writes and removals, and the staged checks.
private IEnumerable < string > StagedKeys = > _deletedKeys . Keys . Concat ( _dirtyKeys . Keys ) . Concat ( StagedChecks ) ;
2026-10-03 15:13:32 +02:00
2026-10-12 04:03:41 +02:00
// A writable store judges a removal of a key its cache does not hold at its next flush or commit, under the commit lease
// (StageChecks); a read-only store has neither, so it answers from its view and never creates the lease.
protected internal override void RefuseRemovalOfUndeclared ( IReadOnlyCollection < string > keys )
{
if ( IsReadOnly )
base . RefuseRemovalOfUndeclared ( keys ) ;
else
StageChecks ( keys ) ;
}
2026-10-14 02:07:19 +02:00
// A read-only store answers a batch's checks from its view and never creates the lease; a writable one reads the manifest
// under the commit lease.
protected internal override CultCommitOutcome JudgeChecks ( IReadOnlyCollection < string > keys , bool wait )
2026-10-12 04:03:41 +02:00
{
2026-10-14 02:07:19 +02:00
if ( IsReadOnly )
return base . JudgeChecks ( keys , wait ) ;
return Held ( ( ) = >
{
using var commitLease = AcquireCommitLease ( wait , create : true ) ;
if ( commitLease = = null )
return CultCommitOutcome . Contended ;
RefuseUndeclaredOnDisk ( ReadManifest ( ) . Snapshot , keys ) ;
return CultCommitOutcome . Committed ;
} ) ;
2026-10-12 04:03:41 +02:00
}
2026-10-03 15:13:32 +02:00
2026-10-14 02:07:19 +02:00
// A manifest record no registered type claims, listed at the version its index record names.
private CultForeignRecord ? ForeignAt ( CultPersistedRecord record , IReadOnlyCollection < CultSchemaCatalogEntry > catalog ) = >
Foreign ( record , catalog , VersionOf ( record . Payload ) ) ;
2026-10-03 13:08:44 +02:00
// The manifest's other records stay as they are, foreign and read-only ones included; a write that would replace or remove a
// record the manifest holds under an id no registered type declares is refused before any page is written, by the one rule every
// store shares (CacheBackingStore.RefuseUndeclaredOnDisk). The refused records reload as a pull loads them.
private void RefuseUndeclaredOnDisk ( CultPersistedStoreSnapshot currentManifest , IEnumerable < string > written )
{
CultPersistedRecord ? HeldAt ( string key ) = >
currentManifest . Records . FirstOrDefault ( record = > string . Equals ( record . Key , key , StringComparison . Ordinal ) ) ;
RefuseUndeclaredOnDisk (
2026-10-03 15:13:32 +02:00
written ,
2026-10-03 13:08:44 +02:00
key = > HeldAt ( key ) is { } held ? Undeclared ( held , currentManifest . SchemaCatalog , Registry . Declares ) : null ,
2026-10-14 02:07:19 +02:00
key = > HeldAt ( key ) is { } held & & Foreign ( held , currentManifest . SchemaCatalog , VersionOf ( held . Payload ) ) = = null
2026-10-03 13:08:44 +02:00
? LoadPage ( held , currentManifest . SchemaCatalog , out _ , out _ )
2026-10-02 18:34:09 +02:00
: null ,
2026-10-12 04:03:41 +02:00
key = > HeldAt ( key ) is { } held ? ForeignAt ( held , currentManifest . SchemaCatalog ) : null ,
2026-10-02 18:34:09 +02:00
key = >
{
_dirtyKeys . TryRemove ( key , out _ ) ;
_deletedKeys . TryRemove ( key , out _ ) ;
IsDirty = ! _dirtyKeys . IsEmpty | | ! _deletedKeys . IsEmpty ;
} ) ;
2026-10-03 13:08:44 +02:00
}
2026-10-02 16:51:54 +02:00
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
// Runs under the commit lease: pages first, then the manifest that names them.
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
private void WriteGeneration ( CultPersistedStoreSnapshot currentManifest , IReadOnlyDictionary < CultSchemaCatalogEntry , byte [ ] > catalogBytes )
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
{
2026-05-31 17:32:42 +01:00
Directory . CreateDirectory ( _recordDirectory . FullName ) ;
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
var currentIndex = currentManifest . Records . ToDictionary ( record = > record . Key , record = > record , StringComparer . Ordinal ) ;
2026-10-03 06:05:41 +02:00
var replaced = currentManifest . Records . ToDictionary ( record = > record . Key , record = > record . StoredAt , StringComparer . Ordinal ) ;
2026-07-20 01:48:56 +01:00
foreach ( var key in _deletedKeys . Keys )
currentIndex . Remove ( key ) ;
foreach ( var key in _dirtyKeys . Keys )
{
if ( Entries . TryGetValue ( key , out var stored ) )
currentIndex [ key ] = ToIndexRecord ( stored ) ;
}
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
var keysToWrite = _dirtyKeys . Keys
2026-07-20 01:48:56 +01:00
. OrderBy ( value = > value , StringComparer . Ordinal )
. ToArray ( ) ;
foreach ( var key in keysToWrite )
2026-05-31 17:32:42 +01:00
{
if ( ! Entries . TryGetValue ( key , out var stored ) )
{
continue ;
}
2026-10-03 06:05:41 +02:00
// A record this write replaces is stored later than it: the one re-mint every store kind shares (StoredAtOver).
if ( replaced . TryGetValue ( key , out var replacedAt ) )
stored . StoredAt = StoredAtOver ( stored . StoredAt , replacedAt ) ;
2026-05-31 17:32:42 +01:00
var record = ToPersistedRecord ( stored , document = >
2026-07-20 01:48:56 +01:00
CultDocumentMessagePackSerialization . SerializeUntyped (
document ,
stored . Descriptor . DocumentType ,
Registry ) ) ;
2026-08-19 00:11:20 +02:00
var pagePayload = CultDocumentMessagePackSerialization . SerializePersistedRecord ( record ) ;
var indexRecord = ToContentAddressedIndexRecord ( record , pagePayload ) ;
CultCache: a record's version is the SHA-256 of its stored bytes; conditions, pulls, the staleness check and a refusal's reload compare it, never storedAt
Adds CultStoredDocument.StoredVersion, CacheBackingStore.VersionOf and the
SingleFileBackingStore.SerializeRecord hook, and swaps every identity comparison
in the same commit: SameStoredIdentity stays the one comparison, over versions.
Deletes the Identity helper, the (schemaId, storedAt) Expected tuple, the
in-memory fake-record projection and the directory's storedAt arrival.
Breaking public API for 2.0.0: CultCommitRequest.Expected, ConditionsHold and
the abstract SingleFileBackingStore.SerializeRecord.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:23:31 +02:00
stored . StoredVersion = VersionOf ( indexRecord . Payload ) ;
2026-10-22 20:59:54 +02:00
stored . StoredSchemaId = stored . Descriptor . SchemaId ;
2026-08-19 00:11:20 +02:00
currentIndex [ key ] = indexRecord ;
2026-07-20 01:48:56 +01:00
WriteFileAtomically (
2026-08-19 00:11:20 +02:00
ContentAddressedRecordPath ( indexRecord ) ,
pagePayload ) ;
2026-07-20 01:48:56 +01:00
}
2026-10-03 03:05:08 +02:00
// The catalog and the header follow the shared write rules: the registered entry publishes only the ids this write put a record
// under, every other record and entry stays as the manifest holds it.
var written = keysToWrite . Where ( key = > Entries . ContainsKey ( key ) ) . ToHashSet ( StringComparer . Ordinal ) ;
Writers derive the catalog from the records they write; the merging is deleted
Every writer (C# single-file and directory, TypeScript, Python; Rust already did) chooses, for each schema id a record carries, one
entry that publishes it: the entry that owns the id (a registered descriptor, else an arrived entry), else one that lists it as
compatible. A registered descriptor wins over an arrived entry with the same own id. Entries are written as chosen, with no union of
compatible ids and no dependence on arrival order; an entry no record needs is not written. Two entries of one tier that share an own id
and disagree on the schema name, records of different types under one id, or a record no chosen entry publishes refuse the write with a
typed error (CultSchemaConflictException, SchemaConflictError) naming the id, the names and a record key, and the store is left as it was.
CultSchemaCatalogEntry.MergeById and the Python and TypeScript merge paths are gone. A cache-held record unchanged since the file was read
is rewritten under its registered id (C# single-file and directory), so an entry that lists an older id is not needed to publish it.
Changelogs for cultnet-py (removed export) and cultcache-py.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:03:34 +02:00
var targetCatalog = CultSchemaCatalogEntry . Derive (
currentIndex . Values ,
2026-10-03 03:05:08 +02:00
written . Select ( key = > Entries [ key ] . Descriptor ) . Distinct ( ) . Select ( descriptor = > descriptor . ToCatalogEntry ( ) ) . ToArray ( ) ,
2026-09-30 11:04:12 +02:00
currentManifest . SchemaCatalog )
Writers derive the catalog from the records they write; the merging is deleted
Every writer (C# single-file and directory, TypeScript, Python; Rust already did) chooses, for each schema id a record carries, one
entry that publishes it: the entry that owns the id (a registered descriptor, else an arrived entry), else one that lists it as
compatible. A registered descriptor wins over an arrived entry with the same own id. Entries are written as chosen, with no union of
compatible ids and no dependence on arrival order; an entry no record needs is not written. Two entries of one tier that share an own id
and disagree on the schema name, records of different types under one id, or a record no chosen entry publishes refuse the write with a
typed error (CultSchemaConflictException, SchemaConflictError) naming the id, the names and a record key, and the store is left as it was.
CultSchemaCatalogEntry.MergeById and the Python and TypeScript merge paths are gone. A cache-held record unchanged since the file was read
is rewritten under its registered id (C# single-file and directory), so an entry that lists an older id is not needed to publish it.
Changelogs for cultnet-py (removed export) and cultcache-py.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:03:34 +02:00
. OrderBy ( entry = > entry . SchemaName , StringComparer . Ordinal )
. ThenBy ( entry = > entry . SchemaId , StringComparer . Ordinal )
. 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
var holdsIds = written . ToDictionary ( key = > key , key = > Entries [ key ] . HoldsIds , StringComparer . Ordinal ) ;
2026-10-03 03:05:08 +02:00
var header = HeaderForWrite (
2026-09-29 21:54:01 +02:00
currentManifest . FormatVersion ,
2026-10-03 07:04:48 +02:00
holdsIds . Values . Select ( holds = > ( bool? ) holds ) . ToArray ( ) ,
copiesAny : currentIndex . Values . Any ( record = > ! written . Contains ( record . Key ) ) ,
2026-10-14 21:52:36 +02:00
copiedHoldIds : false , // only C# writes a directory store, and it marks by content
2026-10-03 03:05:08 +02:00
directoryStore : true ,
holdsVariants : false ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
WriteManifest ( targetCatalog , catalogBytes , currentIndex . Values
2026-07-20 01:48:56 +01:00
. OrderBy ( record = > record . Key , StringComparer . Ordinal )
2026-09-29 21:54:01 +02:00
. ToArray ( ) , header ) ;
2026-07-13 17:18:07 +01:00
2026-08-18 23:42:15 +02:00
DeleteUnreferencedRecordPages ( currentIndex . Values ) ;
2026-07-13 17:18:07 +01:00
_dirtyKeys . Clear ( ) ;
_deletedKeys . Clear ( ) ;
MarkFlushSucceeded ( ) ;
}
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
// The index records are this store's own and are encoded from their fields; a catalog entry the manifest already held is written
// as the slice it held, one the writer built is encoded.
private void WriteManifest (
CultSchemaCatalogEntry [ ] catalog ,
IReadOnlyDictionary < CultSchemaCatalogEntry , byte [ ] > catalogBytes ,
CultPersistedRecord [ ] index ,
string header ) = >
WriteFileAtomically (
_manifestFile . FullName ,
CultDocumentMessagePackSerialization . SerializeStore (
header ,
catalog . Select ( entry = > catalogBytes . TryGetValue ( entry , out var held ) ? held : CultDocumentMessagePackSerialization . SerializeCatalogEntry ( entry ) ) . ToArray ( ) ,
index . Select ( CultDocumentMessagePackSerialization . SerializePersistedRecord ) . ToArray ( ) ) ) ;
2026-05-31 17:32:42 +01:00
2026-07-20 01:48:56 +01:00
private void LoadRecordPages (
CultPersistedRecord [ ] records ,
IReadOnlyCollection < CultSchemaCatalogEntry > catalogEntries ,
Dictionary < string , CultStoredDocument > loaded ,
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
List < CultSchemaMigrationReport > reports ,
List < CultForeignRecord > foreign )
2026-07-20 01:48:56 +01: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 record no registered type claims stays as the manifest names it: its page is never read, and a commit keeps it.
var claimed = new List < CultPersistedRecord > ( records . Length ) ;
foreach ( var metadata in records )
{
2026-10-12 04:03:41 +02:00
if ( ForeignAt ( metadata , catalogEntries ) is { } carried )
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
foreign . Add ( carried ) ;
else
claimed . Add ( metadata ) ;
}
records = claimed . ToArray ( ) ;
2026-07-20 02:38:17 +01:00
var tracePages = string . Equals (
Environment . GetEnvironmentVariable ( "CULTCACHE_TRACE_STARTUP_PHASES" ) ,
"1" ,
StringComparison . Ordinal ) ;
2026-07-20 01:48:56 +01:00
var recordReports = new CultSchemaMigrationReport [ records . Length ] ;
var storedRecords = new CultStoredDocument ? [ records . Length ] ;
2026-07-20 02:38:17 +01:00
var pageBytes = tracePages ? new long [ records . Length ] : Array . Empty < long > ( ) ;
var pageElapsedTicks = tracePages ? new long [ records . Length ] : Array . Empty < long > ( ) ;
2026-09-14 14:51:37 +02:00
try
{
ReadPages ( ) ;
}
catch ( AggregateException aggregate )
{
// The page's own failure propagates, not Parallel.For's wrapper.
ExceptionDispatchInfo . Capture ( aggregate . Flatten ( ) . InnerExceptions [ 0 ] ) . Throw ( ) ;
}
void ReadPages ( ) = > Parallel . For (
2026-07-20 01:48:56 +01:00
0 ,
records . Length ,
new ParallelOptions { MaxDegreeOfParallelism = Math . Min ( Environment . ProcessorCount , 8 ) } ,
index = >
{
2026-07-20 02:38:17 +01:00
var started = tracePages ? Stopwatch . GetTimestamp ( ) : 0L ;
2026-10-03 13:08:44 +02:00
storedRecords [ index ] = LoadPage ( records [ index ] , catalogEntries , out recordReports [ index ] , out var pageLength ) ;
2026-07-20 02:38:17 +01:00
if ( tracePages )
{
2026-10-03 13:08:44 +02:00
pageBytes [ index ] = pageLength ;
2026-07-20 02:38:17 +01:00
pageElapsedTicks [ index ] = Stopwatch . GetTimestamp ( ) - started ;
}
2026-07-20 01:48:56 +01:00
} ) ;
2026-07-20 02:38:17 +01:00
if ( tracePages )
{
var schemaNames = catalogEntries . ToDictionary ( entry = > entry . SchemaId , entry = > entry . SchemaName , StringComparer . Ordinal ) ;
foreach ( var group in records
. Select ( ( record , index ) = > new { record . SchemaId , Index = index } )
. Where ( item = > pageElapsedTicks [ item . Index ] > 0 )
. GroupBy ( item = > item . SchemaId , StringComparer . Ordinal )
. Select ( group = > new
{
SchemaId = group . Key ,
Count = group . Count ( ) ,
Bytes = group . Sum ( item = > pageBytes [ item . Index ] ) ,
TotalMs = group . Sum ( item = > pageElapsedTicks [ item . Index ] ) * 1000d / Stopwatch . Frequency ,
MaxMs = group . Max ( item = > pageElapsedTicks [ item . Index ] ) * 1000d / Stopwatch . Frequency
} )
. OrderByDescending ( group = > group . TotalMs )
. Take ( 10 ) )
{
var schemaName = schemaNames . TryGetValue ( group . SchemaId , out var name ) ? name : group . SchemaId ;
Console . WriteLine (
$"CultCache directory-page schema={schemaName} count={group.Count} bytes={group.Bytes} " +
$"cumulative={group.TotalMs:0.###}ms max={group.MaxMs:0.###}ms." ) ;
}
}
2026-07-20 01:48:56 +01:00
for ( var index = 0 ; index < storedRecords . Length ; index + + )
{
var stored = storedRecords [ index ] ;
if ( stored = = null )
continue ;
reports . Add ( recordReports [ index ] ) ;
loaded [ stored . Key . Value ] = stored ;
}
}
private static CultPersistedRecord ToIndexRecord ( CultStoredDocument stored ) = > new ( )
{
Key = stored . Key . Value ,
SchemaId = stored . Descriptor . SchemaId ,
StoredAt = stored . StoredAt ,
Payload = Array . Empty < byte > ( )
} ;
2026-08-19 00:11:20 +02:00
private static CultPersistedRecord ToContentAddressedIndexRecord (
CultPersistedRecord record ,
byte [ ] pagePayload ) = > new ( )
{
Key = record . Key ,
SchemaId = record . SchemaId ,
StoredAt = record . StoredAt ,
Payload = HashPayload ( pagePayload )
} ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
private ( CultPersistedStoreSnapshot Snapshot , Dictionary < CultSchemaCatalogEntry , byte [ ] > CatalogBytes ) ReadManifest ( ) = > ParseManifest ( ReadManifestBytes ( ) ) ;
2026-09-14 14:16:41 +02:00
private byte [ ] ? ReadManifestBytes ( ) = > File . Exists ( _manifestFile . FullName ) ? ReadAllBytesShared ( _manifestFile . FullName ) : null ;
2026-09-14 14:51:37 +02:00
// A manifest that cannot be re-read (a writer mid-replace) has moved.
private bool ManifestMoved ( byte [ ] ? read )
{
try
{
var current = ReadManifestBytes ( ) ;
return read = = null ? current ! = null : current = = null | | ! read . SequenceEqual ( current ) ;
}
catch ( Exception )
{
return true ;
}
}
2026-09-14 14:16:41 +02:00
2026-09-14 14:33:43 +02:00
// A page the manifest named that vanished or was replaced. It is a writer committing mid-load only when the manifest
// moved too; under an unchanged manifest the same failure is corruption and is thrown as it is.
2026-09-14 14:16:41 +02:00
private static bool IsTornGeneration ( Exception exception ) = >
2026-09-14 14:51:37 +02:00
exception is InvalidDataException or FileNotFoundException or DirectoryNotFoundException ;
2026-09-14 14:16:41 +02:00
private InvalidOperationException Unsettled ( Exception ? last ) = > new (
$"Directory store {_manifestFile.FullName} changed under every one of {UnleasedLoadAttempts} unlocked loads; it did not settle." , last ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
// A missing manifest is an empty store. An existing manifest in any other format is refused. Beside the snapshot, the bytes the
// manifest held for each catalog entry, by the entry this read produced: the slices a write carries forward.
private ( CultPersistedStoreSnapshot Snapshot , Dictionary < CultSchemaCatalogEntry , byte [ ] > CatalogBytes ) ParseManifest ( byte [ ] ? manifestBytes )
2026-05-31 17:32:42 +01:00
{
2026-09-14 14:16:41 +02:00
if ( manifestBytes = = null )
2026-05-31 17:32:42 +01:00
{
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
return ( new CultPersistedStoreSnapshot
2026-05-31 17:32:42 +01:00
{
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
FormatVersion = IndexedFormatVersion ,
SchemaCatalog = Array . Empty < CultSchemaCatalogEntry > ( ) ,
Records = Array . Empty < CultPersistedRecord > ( )
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
} , new Dictionary < CultSchemaCatalogEntry , byte [ ] > ( ) ) ;
2026-05-31 17:32:42 +01:00
}
2026-10-22 20:21:31 +02:00
// The stamp is judged before the manifest body is parsed, so a store stamped newer is refused whatever its body. Every
// write path parses the manifest under its commit lease before it writes a page or the manifest, so a store another
// process has since stamped newer is refused there too, untouched.
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
( CultPersistedStoreSnapshot Snapshot , byte [ ] [ ] CatalogBytes , byte [ ] [ ] RecordBytes ) read ;
2026-09-30 06:54:17 +02:00
try
{
2026-10-22 20:21:31 +02:00
read = CultDocumentMessagePackSerialization . ReadStore ( manifestBytes , RequireReadableStamp ) ;
2026-09-30 06:54:17 +02:00
}
catch ( CultStoreUnreadableException ex ) when ( ex . Path = = null )
{
throw new CultStoreUnreadableException ( ex . Message , _manifestFile . FullName , ex . InnerException ) ;
}
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var snapshot = read . Snapshot ;
var catalogBytes = new Dictionary < CultSchemaCatalogEntry , byte [ ] > ( ) ;
for ( var index = 0 ; index < snapshot . SchemaCatalog . Length ; index + + )
catalogBytes [ snapshot . SchemaCatalog [ index ] ] = read . CatalogBytes [ index ] ;
return ( snapshot , catalogBytes ) ;
2026-05-31 17:32:42 +01:00
}
2026-10-22 20:21:31 +02:00
// The one verdict on a directory manifest's stamp: this runtime reads v4 and v5.
2026-10-22 17:30:57 +02:00
private void RequireReadableStamp ( string stamp )
{
2026-10-22 20:21:31 +02:00
if ( ! string . Equals ( stamp , IndexedFormatVersion , StringComparison . Ordinal ) & &
! string . Equals ( stamp , IndexedFormatVersionWithIds , StringComparison . Ordinal ) )
2026-05-31 17:32:42 +01:00
{
2026-10-22 20:21:31 +02:00
throw new CultStoreUnreadableException (
$"Directory store {_manifestFile.FullName} is {CultDocumentMessagePackSerialization.DescribeHeader(stamp)}; only {IndexedFormatVersion} and {IndexedFormatVersionWithIds} are readable." ,
_manifestFile . FullName ) ;
2026-05-31 17:32:42 +01:00
}
}
2026-08-19 00:11:20 +02:00
private string ContentAddressedRecordPath ( CultPersistedRecord metadata )
{
if ( metadata . Payload = = null | | metadata . Payload . Length ! = 32 )
throw new InvalidDataException ( $"Record '{metadata.Key}' has no committed SHA-256 page identity." ) ;
var hash = BitConverter . ToString ( metadata . Payload ) . Replace ( "-" , string . Empty ) . ToLowerInvariant ( ) ;
return Path . Combine ( _recordDirectory . FullName , $"{hash}.msgpack" ) ;
}
private static byte [ ] HashPayload ( byte [ ] payload )
{
using var sha256 = SHA256 . Create ( ) ;
return sha256 . ComputeHash ( payload ) ;
}
2026-08-18 23:42:15 +02:00
private void DeleteUnreferencedRecordPages ( IEnumerable < CultPersistedRecord > durableRecords )
{
var referenced = durableRecords
2026-08-19 00:11:20 +02:00
. Select ( ContentAddressedRecordPath )
2026-08-18 23:42:15 +02:00
. ToHashSet ( StringComparer . OrdinalIgnoreCase ) ;
foreach ( var file in _recordDirectory . EnumerateFiles ( "*.msgpack" ) )
{
if ( referenced . Contains ( file . FullName ) )
continue ;
try
{
File . Delete ( file . FullName ) ;
}
catch ( IOException )
{
// An orphan is harmless: no committed manifest references it.
}
catch ( UnauthorizedAccessException )
{
// Cleanup is best effort and never changes the committed generation.
}
}
}
2026-05-31 17:32:42 +01:00
2026-10-03 13:08:44 +02:00
// One record as a load decodes it, from the page its manifest record names: a pull's pages and a refusal's reload.
private CultStoredDocument LoadPage (
CultPersistedRecord metadata ,
IReadOnlyCollection < CultSchemaCatalogEntry > catalog ,
out CultSchemaMigrationReport report ,
out long pageLength )
{
var record = ReadPersistedRecordPage ( metadata , out var pagePayload ) ;
pageLength = pagePayload . LongLength ;
var stored = ToStoredDocument (
record ,
VersionOf ( metadata . Payload ) ,
catalog ,
( type , payload ) = > CultDocumentMessagePackSerialization . DeserializeUntyped ( type , payload , Registry ) ,
out report ) ;
// The manifest is this store's record of what is on disk: the stored id and the version a commit condition compares with.
stored . StoredSchemaId = metadata . SchemaId ;
return stored ;
}
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
private CultPersistedRecord ReadPersistedRecordPage ( CultPersistedRecord metadata , out byte [ ] pagePayload )
2026-08-19 02:17:38 +02:00
{
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
var path = ContentAddressedRecordPath ( metadata ) ;
2026-08-19 02:17:38 +02:00
if ( ! File . Exists ( path ) )
throw new InvalidDataException ( $"Committed record page '{path}' is missing from the selected manifest generation." ) ;
pagePayload = ReadAllBytesShared ( path ) ;
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
if ( ! HashPayload ( pagePayload ) . SequenceEqual ( metadata . Payload ) )
2026-08-19 02:17:38 +02:00
throw new InvalidDataException ( $"Record page '{path}' does not match its committed content hash." ) ;
var record = CultDocumentMessagePackSerialization . DeserializePersistedRecord ( pagePayload ) ;
if ( ! string . Equals ( record . Key , metadata . Key , StringComparison . Ordinal ) )
throw new InvalidDataException ( $"Record page '{path}' contains key '{record.Key}', expected '{metadata.Key}'." ) ;
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
// The shared decoder reads a variant page; this store does not hold variants, so a load refuses it as a write does.
if ( record . Variant ! = null )
throw new NotSupportedException (
$"Record '{record.Key}' is a variant of '{record.Variant.BaseKey}'; the directory store does not hold document variants yet." ) ;
2026-08-19 02:17:38 +02:00
return record ;
}
2026-05-31 17:32:42 +01:00
private static void WriteFileAtomically ( string path , byte [ ] payload )
{
var directory = Path . GetDirectoryName ( path ) ! ;
Directory . CreateDirectory ( directory ) ;
var tempPath = Path . Combine ( directory , $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp" ) ;
try
{
using ( var stream = new FileStream (
tempPath ,
FileMode . CreateNew ,
FileAccess . Write ,
FileShare . None ,
bufferSize : 4096 ,
FileOptions . WriteThrough ) )
{
stream . Write ( payload , 0 , payload . Length ) ;
stream . Flush ( true ) ;
}
if ( File . Exists ( path ) )
{
2026-07-02 21:00:36 +01:00
ReplaceExistingFile ( tempPath , path ) ;
2026-05-31 17:32:42 +01:00
}
else
{
File . Move ( tempPath , path ) ;
}
}
finally
{
if ( File . Exists ( tempPath ) )
{
File . Delete ( tempPath ) ;
}
}
}
2026-07-02 21:00:36 +01:00
2026-07-18 18:35:39 +01:00
private static byte [ ] ReadAllBytesShared ( string path )
{
using var stream = new FileStream ( path , FileMode . Open , FileAccess . Read , FileShare . ReadWrite | FileShare . Delete ) ;
using var buffer = new MemoryStream ( ) ;
stream . CopyTo ( buffer ) ;
return buffer . ToArray ( ) ;
}
2026-09-14 14:16:41 +02:00
// Only a writer creates the records folder and the lock, so opening the store creates nothing. A reader opens an
// existing lock and returns null when there is none; that does not mean no writer committed (a store may be copied
// without its lock, and a first writer may create it mid-load), so an unleased load checks that it settled.
2026-09-14 13:43:45 +02:00
private FileStream ? AcquireCommitLease ( bool wait , bool create )
2026-08-19 00:11:20 +02:00
{
2026-09-14 13:43:45 +02:00
if ( create )
Directory . CreateDirectory ( _recordDirectory . FullName ) ;
2026-08-19 00:11:20 +02:00
var lockPath = Path . Combine ( _recordDirectory . FullName , ".commit.lock" ) ;
var started = Stopwatch . StartNew ( ) ;
while ( true )
{
try
{
return new FileStream (
lockPath ,
2026-09-14 13:43:45 +02:00
create ? FileMode . OpenOrCreate : FileMode . Open ,
2026-08-19 00:11:20 +02:00
FileAccess . ReadWrite ,
FileShare . None ,
bufferSize : 1 ,
FileOptions . WriteThrough ) ;
}
2026-09-14 13:43:45 +02:00
catch ( FileNotFoundException ) when ( ! create )
{
return null ;
}
catch ( DirectoryNotFoundException ) when ( ! create )
{
return null ;
}
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
catch ( IOException ) when ( ! wait )
{
return null ;
}
2026-08-19 00:11:20 +02:00
catch ( IOException ) when ( started . Elapsed < TimeSpan . FromSeconds ( 30 ) )
{
Thread . Sleep ( 10 ) ;
}
}
}
2026-07-02 21:00:36 +01:00
private static void ReplaceExistingFile ( string sourcePath , string destinationPath )
{
for ( var attempt = 0 ; attempt < 3 ; attempt + + )
{
try
{
File . Replace ( sourcePath , destinationPath , null ) ;
return ;
}
catch ( IOException ) when ( attempt < 2 )
{
Thread . Sleep ( 10 * ( attempt + 1 ) ) ;
}
catch ( UnauthorizedAccessException ) when ( attempt < 2 )
{
Thread . Sleep ( 10 * ( attempt + 1 ) ) ;
}
}
2026-08-18 23:42:15 +02:00
File . Replace ( sourcePath , destinationPath , null ) ;
2026-07-02 21:00:36 +01:00
}
2026-05-31 17:32:42 +01:00
}