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

@ -8,7 +8,7 @@ namespace GameCult.Caching.MessagePack
{
public CultDocumentRegistry? Registry { get; set; }
public bool PullOnOpen { get; set; } = true;
public bool ReadOnly { get; set; }
public bool FlushOnDispose { get; set; }
@ -19,57 +19,32 @@ namespace GameCult.Caching.MessagePack
public static class CultCacheMessagePack
{
// Opening hydrates. A consumer that wants a fresh store removes the records it does not keep, upserts, and
// flushes; the atomic replace does the rest.
public static CultCache Create(string filePath, CultCacheOpenOptions? options = null)
{
options ??= new CultCacheOpenOptions();
return CreateCore(filePath, options, initializeGlobals: true);
}
public static async Task<CultCache> OpenAsync(string filePath, CultCacheOpenOptions? options = null)
{
options ??= new CultCacheOpenOptions();
var cache = CreateCore(filePath, options, initializeGlobals: false);
if (options.PullOnOpen)
{
await cache.PullAllBackingStoresAsync().ConfigureAwait(false);
}
cache.MaterializeMissingGlobals();
return cache;
}
private static CultCache CreateCore(
string filePath,
CultCacheOpenOptions options,
bool initializeGlobals)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("File path must be non-empty.", nameof(filePath));
}
var cache = new CultCache(options.Registry, initializeGlobals)
options ??= new CultCacheOpenOptions();
var cache = new CultCache(options.Registry)
{
FlushAttachedStoresOnDispose = options.FlushOnDispose
};
if (options.UseDirectoryStore || Directory.Exists(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath)))
{
cache.AddBackingStore(new DirectoryMessagePackBackingStore(filePath)
{
FlushOnDispose = options.StoreFlushOnDispose
});
}
else
{
cache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath)
{
FlushOnDispose = options.StoreFlushOnDispose
});
}
CacheBackingStore store = options.UseDirectoryStore || Directory.Exists(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath))
? new DirectoryMessagePackBackingStore(filePath, readOnly: options.ReadOnly)
: new SingleFileMessagePackBackingStore(filePath, options.ReadOnly);
store.FlushOnDispose = options.StoreFlushOnDispose;
cache.AddBackingStore(store);
return cache;
}
public static Task<CultCache> OpenAsync(string filePath, CultCacheOpenOptions? options = null)
{
return Task.FromResult(Create(filePath, options));
}
}
}

View file

@ -353,7 +353,7 @@ public static class CultDocumentMessagePackSerialization
public class SingleFileMessagePackBackingStore : SingleFileBackingStore
{
public SingleFileMessagePackBackingStore(string filePath) : base(filePath)
public SingleFileMessagePackBackingStore(string filePath, bool readOnly = false) : base(filePath, readOnly)
{
}

View file

@ -24,7 +24,8 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
private Dictionary<string, CultPersistedRecord> _durableIndex = new(StringComparer.Ordinal);
private CultSchemaCatalogEntry[] _durableCatalog = Array.Empty<CultSchemaCatalogEntry>();
public DirectoryMessagePackBackingStore(string manifestPath, string? recordDirectoryPath = null)
public DirectoryMessagePackBackingStore(string manifestPath, string? recordDirectoryPath = null, bool readOnly = false)
: base(readOnly)
{
if (string.IsNullOrWhiteSpace(manifestPath))
{
@ -37,6 +38,8 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
public static string DefaultRecordDirectoryPath(string manifestPath) => manifestPath + ".records";
public override string ToString() => _manifestFile.FullName;
public override void PullAll()
{
lock (_mutationGate)
@ -59,7 +62,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
var reports = new List<CultSchemaMigrationReport>();
var loaded = new Dictionary<string, CultStoredDocument>(StringComparer.Ordinal);
using (AcquireCommitLease())
using (AcquireCommitLease(wait: true))
{
// Only pages named by the manifest read under the lease are loaded; orphaned pages are never loaded.
var manifest = ReadManifest();
@ -78,32 +81,26 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
Trace($"indexed-pages loaded={loaded.Count}");
}
foreach (var pair in loaded)
{
if (_dirtyKeys.ContainsKey(pair.Key) || _deletedKeys.ContainsKey(pair.Key))
continue;
if (Entries.TryGetValue(pair.Key, out var existing) &&
string.Equals(existing.StoredAt, pair.Value.StoredAt, StringComparison.Ordinal) &&
string.Equals(existing.Descriptor.SchemaId, pair.Value.Descriptor.SchemaId, StringComparison.Ordinal))
{
continue;
}
Entries[pair.Key] = pair.Value;
Loaded?.Invoke(pair.Value);
}
foreach (var removedKey in _hydratedKeys.Where(key => !loaded.ContainsKey(key)).ToArray())
{
if (_dirtyKeys.ContainsKey(removedKey) || _deletedKeys.ContainsKey(removedKey))
continue;
if (Entries.TryRemove(removedKey, out var removed))
Unloaded?.Invoke(removed);
_hydratedKeys.Remove(removedKey);
}
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) ||
existing.StoredAt != pair.Value.StoredAt ||
existing.Descriptor.SchemaId != pair.Value.Descriptor.SchemaId))
.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);
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;
foreach (var key in loaded.Keys)
_hydratedKeys.Add(key);
@ -112,14 +109,9 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
Trace("publish");
}
public override bool ContainsDurableRecord(CultRecordKey key)
{
lock (_mutationGate)
return _durableIndex.ContainsKey(key.Value) || base.ContainsDurableRecord(key);
}
public override void Push(CultStoredDocument entry)
{
ThrowIfReadOnly();
lock (_mutationGate)
{
Entries[entry.Key.Value] = entry;
@ -131,6 +123,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
public override void Delete(CultStoredDocument entry)
{
ThrowIfReadOnly();
lock (_mutationGate)
{
Entries.TryRemove(entry.Key.Value, out _);
@ -140,32 +133,40 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
}
}
public override void CommitBatch(
IReadOnlyCollection<CultStoredDocument> upserts,
IReadOnlyCollection<CultStoredDocument> deletes)
public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait)
{
ThrowIfReadOnly();
lock (_mutationGate)
{
Directory.CreateDirectory(_manifestFile.DirectoryName!);
using var commitLease = AcquireCommitLease(wait);
if (commitLease == null)
return CultCommitOutcome.Contended;
var manifest = ReadManifest();
if (!request.ConditionsHold(manifest.Records, Entries.Values))
return CultCommitOutcome.Mismatch;
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
{
foreach (var entry in deletes)
foreach (var entry in request.Deletes)
{
Entries.TryRemove(entry.Key.Value, out _);
_dirtyKeys.TryRemove(entry.Key.Value, out _);
_deletedKeys[entry.Key.Value] = true;
}
foreach (var entry in upserts)
foreach (var entry in request.Upserts)
{
Entries[entry.Key.Value] = entry;
_dirtyKeys[entry.Key.Value] = true;
_deletedKeys.TryRemove(entry.Key.Value, out _);
}
IsDirty = true;
PushAllCore();
WriteGeneration(manifest);
return CultCommitOutcome.Committed;
}
catch
{
@ -186,22 +187,22 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
public override void PushAll()
{
ThrowIfReadOnly();
lock (_mutationGate)
PushAllCore();
{
if (!IsDirty)
return;
Directory.CreateDirectory(_manifestFile.DirectoryName!);
using var commitLease = AcquireCommitLease(wait: true);
WriteGeneration(ReadManifest());
}
}
private void PushAllCore()
// Runs under the commit lease: pages first, then the manifest that names them.
private void WriteGeneration(CultPersistedStoreSnapshot currentManifest)
{
if (!IsDirty)
{
return;
}
Directory.CreateDirectory(_manifestFile.DirectoryName!);
Directory.CreateDirectory(_recordDirectory.FullName);
using var commitLease = AcquireCommitLease();
var currentManifest = ReadManifest();
var currentIndex = currentManifest.Records.ToDictionary(record => record.Key, record => record, StringComparer.Ordinal);
foreach (var key in _deletedKeys.Keys)
currentIndex.Remove(key);
@ -292,11 +293,10 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
var started = tracePages ? Stopwatch.GetTimestamp() : 0L;
var metadata = records[index];
var record = ReadPersistedRecordPage(metadata, out var pagePayload);
var catalog = ResolveLegacyUncataloguedRecordCatalog(record, catalogEntries);
recordReports[index] = Registry.ResolvePersistedSchemaReport(record.SchemaId, catalog);
recordReports[index] = Registry.ResolvePersistedSchemaReport(record.SchemaId, catalogEntries);
storedRecords[index] = ToStoredDocument(
record,
catalog,
catalogEntries,
(type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry));
if (tracePages)
{
@ -381,80 +381,6 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
return snapshot;
}
// Explicit compatibility path for records written before catalog precommit existed.
private IReadOnlyCollection<CultSchemaCatalogEntry> ResolveLegacyUncataloguedRecordCatalog(
CultPersistedRecord record,
IReadOnlyCollection<CultSchemaCatalogEntry> manifestCatalog)
{
if (manifestCatalog.Any(entry => string.Equals(entry.SchemaId, record.SchemaId, StringComparison.Ordinal)))
{
return manifestCatalog;
}
var schemaVersion = TryReadPayloadSchemaVersion(record.Payload);
if (string.IsNullOrWhiteSpace(schemaVersion))
{
return manifestCatalog;
}
var schemaName = InferSchemaName(schemaVersion);
if (string.IsNullOrWhiteSpace(schemaName))
{
return manifestCatalog;
}
return manifestCatalog
.Append(new CultSchemaCatalogEntry
{
SchemaId = record.SchemaId,
SchemaName = schemaName,
SchemaVersion = schemaVersion,
ContentHash = record.SchemaId,
CanonicalSchemaJson = "",
CompatibleSchemaIds = Array.Empty<string>(),
Members = Array.Empty<CultSchemaMemberCatalogEntry>()
})
.ToArray();
}
private static string TryReadPayloadSchemaVersion(byte[] payload)
{
if (payload == null || payload.Length == 0)
{
return "";
}
try
{
var reader = new MessagePackReader(payload);
if (reader.NextMessagePackType != MessagePackType.Array)
{
return "";
}
if (reader.ReadArrayHeader() <= 0)
{
return "";
}
return reader.NextMessagePackType == MessagePackType.String
? reader.ReadString() ?? ""
: "";
}
catch
{
return "";
}
}
private static string InferSchemaName(string schemaVersion)
{
var versionMarker = schemaVersion.LastIndexOf(".v", StringComparison.Ordinal);
return versionMarker > 0 && versionMarker + 2 < schemaVersion.Length && char.IsDigit(schemaVersion[versionMarker + 2])
? schemaVersion.Substring(0, versionMarker)
: "";
}
private string ContentAddressedRecordPath(CultPersistedRecord metadata)
{
if (metadata.Payload == null || metadata.Payload.Length != 32)
@ -553,7 +479,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
return buffer.ToArray();
}
private FileStream AcquireCommitLease()
private FileStream? AcquireCommitLease(bool wait)
{
Directory.CreateDirectory(_recordDirectory.FullName);
var lockPath = Path.Combine(_recordDirectory.FullName, ".commit.lock");
@ -570,6 +496,10 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
bufferSize: 1,
FileOptions.WriteThrough);
}
catch (IOException) when (!wait)
{
return null;
}
catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30))
{
Thread.Sleep(10);

File diff suppressed because it is too large Load diff

View file

@ -805,7 +805,7 @@ namespace GameCult.Networking
ThrowIfDisposed();
if (document == null) throw new ArgumentNullException(nameof(document));
var descriptor = _cache.Registry.GetRequired<T>();
var descriptor = _cache.Registry.GetRequired(document.GetType());
var shard = ResolveShardInternal(descriptor, key);
EnsurePrimary(shard, descriptor.SchemaId, key);
@ -840,7 +840,7 @@ namespace GameCult.Networking
ThrowIfDisposed();
if (document == null) throw new ArgumentNullException(nameof(document));
var descriptor = _cache.Registry.GetRequired<T>();
var descriptor = _cache.Registry.GetRequired(document.GetType());
var shard = ResolveShardInternal(descriptor, key);
EnsureClientAuthority(descriptor, key);

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)

View file

@ -517,7 +517,7 @@ namespace GameCult.Unity.Caching.Editor
if (!string.IsNullOrEmpty(selected))
{
_path = selected;
OpenPath(true);
OpenPath();
}
}
@ -528,16 +528,16 @@ namespace GameCult.Unity.Caching.Editor
if (!string.IsNullOrEmpty(selected))
{
_path = selected;
OpenPath(false);
OpenPath();
SaveCurrent();
}
}
private void OpenPath(bool pullOnOpen)
private void OpenPath()
{
try
{
_cache = CultCacheBridge.Open(_path, pullOnOpen);
_cache = CultCacheBridge.Open(_path);
_selectedType = CultCacheBridge.GetDescriptors(_cache).FirstOrDefault()?.DocumentType;
_selectedKey = string.Empty;
EditorPrefs.SetString(LastPathKey, _path);
@ -680,11 +680,10 @@ namespace GameCult.Unity.Caching.Editor
public static bool IsAvailable => CacheType != null && OpenOptionsType != null && MessagePackType != null && RecordKeyType != null;
public static object Open(string path, bool pullOnOpen)
public static object Open(string path)
{
EnsureAvailable();
var options = Activator.CreateInstance(OpenOptionsType);
OpenOptionsType.GetProperty("PullOnOpen").SetValue(options, pullOnOpen, null);
var method = MessagePackType.GetMethod("OpenAsync", new[] { typeof(string), OpenOptionsType });
var task = (Task)method.Invoke(null, new[] { path, options });
task.GetAwaiter().GetResult();

View file

@ -285,14 +285,11 @@ namespace GameCult.Caching.Tests
}
[Test]
public async Task CultCacheMessagePack_OpenAsync_HydratesPersistedGlobalBeforeDefault()
public async Task CultCacheMessagePack_OpenAsync_HydratesPersistedGlobal()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(GlobalTestEntry) });
typeof(CultDocumentDescriptor)
.GetField("<IsGlobal>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(registry.GetRequired<GlobalTestEntry>(), true);
try
{
@ -300,10 +297,8 @@ namespace GameCult.Caching.Tests
filePath,
new CultCacheOpenOptions { Registry = registry, UseDirectoryStore = true }))
{
var global = seed.GetGlobal<GlobalTestEntry>();
Assert.That(global, Is.Not.Null);
global!.Value = "durable authority";
await seed.UpsertAsync(global);
Assert.That(seed.GetGlobal<GlobalTestEntry>(), Is.Null, "opening a store invented a global");
await seed.UpsertAsync(new GlobalTestEntry { Value = "durable authority" });
await seed.FlushAsync();
}
@ -312,8 +307,7 @@ namespace GameCult.Caching.Tests
new CultCacheOpenOptions { Registry = registry, UseDirectoryStore = true });
Assert.That(reopened.GetGlobal<GlobalTestEntry>()?.Value, Is.EqualTo("durable authority"));
Assert.That(reopened.IsDirty, Is.False,
"a hydrated durable global must not stage its schema default as a write");
Assert.That(reopened.IsDirty, Is.False, "hydrating a global staged a write");
}
finally
{
@ -446,15 +440,16 @@ namespace GameCult.Caching.Tests
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
try
{
var readerStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var readerCache = new CultCache();
readerCache.AddBackingStore(readerStore);
var writerStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var writerCache = new CultCache();
writerCache.AddBackingStore(writerStore);
await writerCache.UpsertAsync(new NamedTestEntry { Name = "source", Value = "one" });
writerStore.PushAll();
var readerStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
var readerCache = new CultCache();
readerCache.AddBackingStore(readerStore);
var observerRan = false;
using var subscription = readerCache.Watch<NamedTestEntry>().Subscribe(_ =>
{
@ -885,49 +880,40 @@ namespace GameCult.Caching.Tests
}
[Test]
public async Task CultCache_Transaction_Hides_Staged_Records_Until_Whole_Batch_Is_Durable()
public async Task CultCache_Commit_Hides_Batch_Until_The_Directory_Store_Has_Committed_It()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-transaction-{Guid.NewGuid():N}");
var root = Path.Combine(Path.GetTempPath(), $"cultcache-commit-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
Directory.CreateDirectory(root);
try
{
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var firstKey = new CultRecordKey("transaction:first");
var secondKey = new CultRecordKey("transaction:second");
var staged = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var firstKey = new CultRecordKey("commit:first");
var secondKey = new CultRecordKey("commit:second");
var observed = 0;
using var subscription = cache.Watch<NamedTestEntry>().Subscribe(_ => observed++);
var transaction = cache.ExecuteTransactionAsync(async () =>
var committed = cache.Commit(batch =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "first", Value = "one" },
new CultRecordHandle<NamedTestEntry>(firstKey));
staged.SetResult(true);
await release.Task;
await cache.UpsertAsync(
new NamedTestEntry { Name = "second", Value = "two" },
new CultRecordHandle<NamedTestEntry>(secondKey));
batch.Upsert(new NamedTestEntry { Name = "first", Value = "one" }, new CultRecordHandle<NamedTestEntry>(firstKey));
Assert.That(cache.Get<NamedTestEntry>(firstKey), Is.Null);
Assert.That(File.Exists(manifest), Is.False);
batch.Upsert(new NamedTestEntry { Name = "second", Value = "two" }, new CultRecordHandle<NamedTestEntry>(secondKey));
Assert.That(observed, Is.Zero);
});
await staged.Task;
Assert.That(cache.Get<NamedTestEntry>(firstKey), Is.Null);
Assert.That(observed, Is.Zero);
release.SetResult(true);
await transaction;
Assert.That(committed, Is.True);
Assert.That(cache.Get<NamedTestEntry>(firstKey)?.Value, Is.EqualTo("one"));
Assert.That(cache.Get<NamedTestEntry>(secondKey)?.Value, Is.EqualTo("two"));
Assert.That(observed, Is.EqualTo(2));
Assert.That(cache.IsDirty, Is.False);
using var reopened = new CultCache();
reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await reopened.PullAllBackingStoresAsync();
Assert.That(reopened.Get<NamedTestEntry>(firstKey)?.Value, Is.EqualTo("one"));
Assert.That(reopened.Get<NamedTestEntry>(secondKey)?.Value, Is.EqualTo("two"));
await Task.CompletedTask;
}
finally
{
@ -936,7 +922,7 @@ namespace GameCult.Caching.Tests
}
[Test]
public async Task CultCache_Transaction_Abort_Cannot_Leak_Into_Later_Commit()
public void CultCache_Commit_Stage_Failure_Cannot_Leak_Into_Later_Commit()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-abort-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
@ -945,33 +931,25 @@ namespace GameCult.Caching.Tests
{
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var abortedKey = new CultRecordKey("transaction:aborted");
var committedKey = new CultRecordKey("transaction:committed");
var abortedKey = new CultRecordKey("commit:aborted");
var committedKey = new CultRecordKey("commit:committed");
var observed = 0;
using var subscription = cache.Watch<NamedTestEntry>().Subscribe(_ => observed++);
Assert.ThrowsAsync<InvalidOperationException>(async () =>
await cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "aborted", Value = "must-not-leak" },
new CultRecordHandle<NamedTestEntry>(abortedKey));
throw new InvalidOperationException("abort probe");
}));
Assert.Throws<InvalidOperationException>(() => cache.Commit(batch =>
{
batch.Upsert(new NamedTestEntry { Name = "aborted", Value = "must-not-leak" }, new CultRecordHandle<NamedTestEntry>(abortedKey));
throw new InvalidOperationException("abort probe");
}));
Assert.That(cache.Get<NamedTestEntry>(abortedKey), Is.Null);
Assert.That(observed, Is.Zero);
await cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "committed", Value = "survives" },
new CultRecordHandle<NamedTestEntry>(committedKey));
});
Assert.That(cache.Commit(batch =>
batch.Upsert(new NamedTestEntry { Name = "committed", Value = "survives" }, new CultRecordHandle<NamedTestEntry>(committedKey))), Is.True);
Assert.That(observed, Is.EqualTo(1));
using var reopened = new CultCache();
reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await reopened.PullAllBackingStoresAsync();
Assert.That(reopened.Get<NamedTestEntry>(abortedKey), Is.Null);
Assert.That(reopened.Get<NamedTestEntry>(committedKey)?.Value, Is.EqualTo("survives"));
}
@ -982,45 +960,33 @@ namespace GameCult.Caching.Tests
}
[Test]
public async Task CultCache_Transaction_Rejects_Escaped_Async_Writes_After_Commit()
public void CultCache_Commit_Batch_Used_After_Commit_Throws()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-escaped-transaction-{Guid.NewGuid():N}");
var root = Path.Combine(Path.GetTempPath(), $"cultcache-escaped-batch-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
Directory.CreateDirectory(root);
try
{
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var committedKey = new CultRecordKey("transaction:committed-parent");
var escapedKey = new CultRecordKey("transaction:escaped-child");
var releaseChild = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Task? escapedWrite = null;
var committedKey = new CultRecordKey("commit:committed-parent");
var escapedKey = new CultRecordKey("commit:escaped-child");
CultCacheBatch? escaped = null;
await cache.ExecuteTransactionAsync(async () =>
cache.Commit(batch =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "parent", Value = "committed" },
new CultRecordHandle<NamedTestEntry>(committedKey));
escapedWrite = Task.Run(async () =>
{
await releaseChild.Task;
await cache.UpsertAsync(
new NamedTestEntry { Name = "child", Value = "must-fail" },
new CultRecordHandle<NamedTestEntry>(escapedKey));
});
batch.Upsert(new NamedTestEntry { Name = "parent", Value = "committed" }, new CultRecordHandle<NamedTestEntry>(committedKey));
escaped = batch;
});
releaseChild.SetResult(true);
Assert.That(escapedWrite, Is.Not.Null);
Assert.That(
async () => await escapedWrite!,
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already completed"));
() => escaped!.Upsert(new NamedTestEntry { Name = "child", Value = "must-fail" }, new CultRecordHandle<NamedTestEntry>(escapedKey)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already been committed"));
Assert.That(cache.Get<NamedTestEntry>(committedKey)?.Value, Is.EqualTo("committed"));
Assert.That(cache.Get<NamedTestEntry>(escapedKey), Is.Null);
using var reopened = new CultCache();
reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await reopened.PullAllBackingStoresAsync();
Assert.That(reopened.Get<NamedTestEntry>(committedKey)?.Value, Is.EqualTo("committed"));
Assert.That(reopened.Get<NamedTestEntry>(escapedKey), Is.Null);
}
@ -1031,118 +997,32 @@ namespace GameCult.Caching.Tests
}
[Test]
public async Task CultCache_Transaction_Rejects_Hydration_Inside_Its_Mutation_Scope()
public void CultCache_Commit_Observer_Writes_In_A_New_Commit()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-transaction-hydration-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
Directory.CreateDirectory(root);
try
{
using (var writer = new CultCache())
{
writer.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await writer.UpsertAsync(
new NamedTestEntry { Name = "external", Value = "durable" },
new CultRecordHandle<NamedTestEntry>(new CultRecordKey("transaction:external")));
writer.FlushAllBackingStores();
}
using var reader = new CultCache();
reader.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var observed = 0;
using var subscription = reader.Watch<NamedTestEntry>().Subscribe(_ => observed++);
Assert.That(
async () => await reader.ExecuteTransactionAsync(
() => reader.PullAllBackingStoresAsync()),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("hydrate before"));
Assert.That(reader.Get<NamedTestEntry>(new CultRecordKey("transaction:external")), Is.Null);
Assert.That(observed, Is.Zero);
await reader.PullAllBackingStoresAsync();
Assert.That(reader.Get<NamedTestEntry>(new CultRecordKey("transaction:external"))?.Value,
Is.EqualTo("durable"));
Assert.That(observed, Is.EqualTo(1));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Test]
public async Task CultCache_Hydration_Waits_For_The_Active_Mutation_Transaction()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-hydration-gate-{Guid.NewGuid():N}");
var root = Path.Combine(Path.GetTempPath(), $"cultcache-observer-commit-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
Directory.CreateDirectory(root);
try
{
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var staged = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var transaction = cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "staged", Value = "committed" },
new CultRecordHandle<NamedTestEntry>(new CultRecordKey("transaction:hydration-gate")));
staged.SetResult(true);
await release.Task;
});
await staged.Task;
var hydration = cache.PullAllBackingStoresAsync();
Assert.That(hydration.IsCompleted, Is.False,
"hydration must wait until the active mutation transaction has committed");
release.SetResult(true);
await transaction;
await hydration;
Assert.That(cache.Get<NamedTestEntry>(new CultRecordKey("transaction:hydration-gate"))?.Value,
Is.EqualTo("committed"));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Test]
public async Task CultCache_Transaction_Observer_Writes_In_A_New_Commit()
{
var root = Path.Combine(Path.GetTempPath(), $"cultcache-observer-transaction-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "state.cc");
Directory.CreateDirectory(root);
try
{
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
var firstKey = new CultRecordKey("transaction:observer-source");
var secondKey = new CultRecordKey("transaction:observer-write");
var firstKey = new CultRecordKey("commit:observer-source");
var secondKey = new CultRecordKey("commit:observer-write");
using var subscription = cache.Watch<NamedTestEntry>().Subscribe(change =>
{
if (!change.Key.Equals(firstKey)) return;
cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "observer", Value = "separate-commit" },
new CultRecordHandle<NamedTestEntry>(secondKey));
}).GetAwaiter().GetResult();
cache.Commit(batch => batch.Upsert(
new NamedTestEntry { Name = "observer", Value = "separate-commit" },
new CultRecordHandle<NamedTestEntry>(secondKey)));
});
await cache.ExecuteTransactionAsync(async () =>
{
await cache.UpsertAsync(
new NamedTestEntry { Name = "source", Value = "committed-first" },
new CultRecordHandle<NamedTestEntry>(firstKey));
});
cache.Commit(batch => batch.Upsert(
new NamedTestEntry { Name = "source", Value = "committed-first" },
new CultRecordHandle<NamedTestEntry>(firstKey)));
Assert.That(cache.Get<NamedTestEntry>(secondKey)?.Value, Is.EqualTo("separate-commit"));
using var reopened = new CultCache();
reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await reopened.PullAllBackingStoresAsync();
Assert.That(reopened.Get<NamedTestEntry>(firstKey)?.Value, Is.EqualTo("committed-first"));
Assert.That(reopened.Get<NamedTestEntry>(secondKey)?.Value, Is.EqualTo("separate-commit"));
}
@ -1170,6 +1050,7 @@ namespace GameCult.Caching.Tests
}
[CultDocument("tests.global_entry", "tests.global_entry.v1")]
[CultGlobal]
internal sealed class GlobalTestEntry
{
[Key(0)]

View file

@ -104,11 +104,10 @@ namespace GameCult.Caching.Tests
File.WriteAllBytes(manifest, bytes);
using var cache = new CultCache();
var store = new DirectoryMessagePackBackingStore(manifest);
cache.AddBackingStore(store);
Assert.That(() => store.PullAll(),
Assert.That(() => cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains(formatVersion));
Assert.That(cache.BackingStores, Is.Empty);
Assert.That(File.ReadAllBytes(manifest), Is.EqualTo(bytes));
}
finally

View file

@ -1761,8 +1761,7 @@ public sealed class CultMeshStreamingTests
StartServer = false,
CacheOptions = new CultCacheOpenOptions
{
Registry = sourceCache.Registry,
PullOnOpen = false
Registry = sourceCache.Registry
},
DatabaseOptions = new CultNetDatabaseOptions
{
@ -1820,8 +1819,7 @@ public sealed class CultMeshStreamingTests
StartServer = false,
CacheOptions = new CultCacheOpenOptions
{
Registry = sourceCache.Registry,
PullOnOpen = false
Registry = sourceCache.Registry
},
DatabaseOptions = new CultNetDatabaseOptions
{
@ -1846,8 +1844,7 @@ public sealed class CultMeshStreamingTests
StartServer = false,
CacheOptions = new CultCacheOpenOptions
{
Registry = sourceCache.Registry,
PullOnOpen = false
Registry = sourceCache.Registry
},
DatabaseOptions = new CultNetDatabaseOptions
{
@ -1899,8 +1896,7 @@ public sealed class CultMeshStreamingTests
StartServer = false,
CacheOptions = new CultCacheOpenOptions
{
Registry = sourceCache.Registry,
PullOnOpen = false
Registry = sourceCache.Registry
},
DatabaseOptions = new CultNetDatabaseOptions
{
@ -2760,8 +2756,7 @@ public sealed class CultMeshStreamingTests
StartServer = false,
CacheOptions = new CultCacheOpenOptions
{
Registry = sourceCache.Registry,
PullOnOpen = false
Registry = sourceCache.Registry
},
DatabaseOptions = new CultNetDatabaseOptions
{
@ -2835,8 +2830,7 @@ public sealed class CultMeshStreamingTests
StartServer = false,
CacheOptions = new CultCacheOpenOptions
{
Registry = targetCache.Registry,
PullOnOpen = false
Registry = targetCache.Registry
},
DatabaseOptions = new CultNetDatabaseOptions
{
@ -2993,8 +2987,7 @@ public sealed class CultMeshStreamingTests
StartServer = false,
CacheOptions = new CultCacheOpenOptions
{
Registry = cacheRegistry,
PullOnOpen = false
Registry = cacheRegistry
},
DatabaseOptions = new CultNetDatabaseOptions
{
@ -3030,8 +3023,7 @@ public sealed class CultMeshStreamingTests
StartServer = false,
CacheOptions = new CultCacheOpenOptions
{
Registry = sourceCache.Registry,
PullOnOpen = false
Registry = sourceCache.Registry
},
DatabaseOptions = new CultNetDatabaseOptions
{
@ -3170,8 +3162,7 @@ public sealed class CultMeshStreamingTests
StartServer = false,
CacheOptions = new CultCacheOpenOptions
{
Registry = sourceCache.Registry,
PullOnOpen = false
Registry = sourceCache.Registry
},
DatabaseOptions = new CultNetDatabaseOptions
{