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>
This commit is contained in:
Meta Vens 2026-09-13 17:57:58 +02:00
commit a3cc394241
10 changed files with 767 additions and 999 deletions

View file

@ -248,18 +248,12 @@ namespace GameCult.Networking
});
}
private async Task DeleteAsync(CultNetDatabaseChangeRawMessage message)
private Task DeleteAsync(CultNetDatabaseChangeRawMessage message)
{
if (string.IsNullOrWhiteSpace(message.RecordKey) || string.IsNullOrWhiteSpace(message.SchemaId)) return;
var descriptor = _documents.ResolveDescriptorForSchemaId(message.SchemaId!);
var handle = Activator.CreateInstance(
typeof(CultRecordHandle<>).MakeGenericType(descriptor.DocumentType),
new object[] { new CultRecordKey(message.RecordKey!) });
var task = (Task)typeof(CultCache)
.GetMethod(nameof(CultCache.DeleteAsync), BindingFlags.Public | BindingFlags.Instance)!
.MakeGenericMethod(descriptor.DocumentType)
.Invoke(_cache, new[] { handle })!;
await task.ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(message.RecordKey) || string.IsNullOrWhiteSpace(message.SchemaId)) return Task.CompletedTask;
_documents.ResolveDescriptorForSchemaId(message.SchemaId!);
_cache.Remove(new CultRecordKey(message.RecordKey!));
return Task.CompletedTask;
}
private void Enqueue(Func<Task> operation)