diff --git a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs index 3a57d960..16bbd6c9 100644 --- a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs +++ b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs @@ -70,15 +70,39 @@ namespace GameCult.Caching.MessagePack /// Creates a cache with the canonical single-file MessagePack backing store attached. /// public static CultCache Create(string filePath, CultCacheOpenOptions? options = null) + { + options ??= new CultCacheOpenOptions(); + return CreateCore(filePath, options, initializeGlobals: true); + } + + /// + /// Creates a cache with the canonical single-file MessagePack backing store attached and optionally pulls the on-disk snapshot. + /// + public static async Task 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)); } - options ??= new CultCacheOpenOptions(); - - var cache = new CultCache(options.Registry) + var cache = new CultCache(options.Registry, initializeGlobals) { FlushAttachedStoresOnDispose = options.FlushOnDispose }; @@ -106,20 +130,5 @@ namespace GameCult.Caching.MessagePack return cache; } - - /// - /// Creates a cache with the canonical single-file MessagePack backing store attached and optionally pulls the on-disk snapshot. - /// - public static async Task OpenAsync(string filePath, CultCacheOpenOptions? options = null) - { - options ??= new CultCacheOpenOptions(); - var cache = Create(filePath, options); - if (options.PullOnOpen) - { - await cache.PullAllBackingStoresAsync().ConfigureAwait(false); - } - - return cache; - } } } diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 69f6fb26..003b4863 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -154,6 +154,12 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore IsDirty = !_dirtyKeys.IsEmpty || !_deletedKeys.IsEmpty; } + /// + public override bool ContainsDurableRecord(CultRecordKey key) + { + return _durableIndex.ContainsKey(key.Value) || base.ContainsDurableRecord(key); + } + /// public override void Push(CultStoredDocument entry) { diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index a580604b..d3da3096 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -1284,8 +1284,29 @@ namespace GameCult.Caching /// Creates a cache using the supplied document registry or the shared registry. /// public CultCache(CultDocumentRegistry? registry = null) + : this(registry, initializeGlobals: true) + { + } + + /// + /// Creates a cache using the supplied document registry, optionally deferring global defaults + /// until a durable backing store has been hydrated. + /// + public CultCache(CultDocumentRegistry? registry, bool initializeGlobals) { _registry = registry ?? CultDocumentRegistry.Shared; + if (initializeGlobals) + { + MaterializeMissingGlobals(); + } + } + + /// + /// Creates default instances for global document types that do not already have a record. + /// Durable open paths call this after hydration so persisted globals remain authoritative. + /// + public void MaterializeMissingGlobals() + { InitializeGlobals(); } @@ -1800,6 +1821,13 @@ namespace GameCult.Caching { foreach (var descriptor in _registry.AllDescriptors.Where(candidate => candidate.IsGlobal)) { + var key = new CultRecordKey($"global:{descriptor.SchemaId}"); + if (_entries.ContainsKey(key.Value) || + _backingStores.Any(store => store.ContainsDurableRecord(key))) + { + continue; + } + if (descriptor.DocumentType.GetConstructor(Type.EmptyTypes) == null) { continue; @@ -1813,7 +1841,7 @@ namespace GameCult.Caching AddStoredDocumentInternal( new CultStoredDocument( - new CultRecordKey($"global:{descriptor.SchemaId}"), + key, DateTimeOffset.UtcNow.ToString("O"), descriptor, instance), @@ -2037,6 +2065,13 @@ namespace GameCult.Caching PullAll(); } /// + /// Returns whether the backing store knows a record is durable, including records left cold by selective hydration. + /// + public virtual bool ContainsDurableRecord(CultRecordKey key) + { + return Entries.ContainsKey(key.Value); + } + /// /// Pushes one stored document into the backing store. /// public abstract void Push(CultStoredDocument entry); diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 23e37cb4..15d6d718 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -321,6 +322,54 @@ namespace GameCult.Caching.Tests } } + [Test] + public async Task CultCacheMessagePack_OpenAsync_HydratesPersistedGlobalBeforeDefault() + { + 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("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(registry.GetRequired(), true); + + try + { + using (var seed = await CultCacheMessagePack.OpenAsync( + filePath, + new CultCacheOpenOptions { Registry = registry, UseDirectoryStore = true })) + { + var global = seed.GetGlobal(); + Assert.That(global, Is.Not.Null); + global!.Value = "durable authority"; + await seed.UpsertAsync(global); + await seed.FlushAsync(); + } + + using var reopened = await CultCacheMessagePack.OpenAsync( + filePath, + new CultCacheOpenOptions + { + Registry = registry, + UseDirectoryStore = true, + DirectoryStoreHydrationFilter = _ => false + }); + + Assert.That(reopened.GetGlobal(), Is.Null, + "the hydration filter owns whether the durable global becomes hot"); + Assert.That(reopened.IsDirty, Is.False, + "a cold durable global must not stage its schema default as a write"); + + var globalKey = "global:" + registry.GetRequired().SchemaId; + await reopened.PullBackingStoreRecordsAsync(metadata => metadata.Key == globalKey); + Assert.That(reopened.GetGlobal()?.Value, Is.EqualTo("durable authority")); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); + } + } + [Test] public async Task DirectoryMessagePackBackingStore_CleanFlush_DoesNotRewriteManifest() { @@ -989,6 +1038,13 @@ namespace GameCult.Caching.Tests public string Value = string.Empty; } + [CultDocument("tests.global_entry", "tests.global_entry.v1")] + internal sealed class GlobalTestEntry + { + [Key(0)] + public string Value = "schema default"; + } + [CultDocument("tests.reference_holder", "tests.reference_holder.v1")] internal sealed class ReferenceHolderEntry {