diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 648ba09e..6af00795 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -89,9 +89,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore .OrderBy(record => record.Key, StringComparer.Ordinal) .Select(record => ReadPersistedRecordPage(record, manifest.FormatVersion, out _)) .ToArray() - : manifest.Records - .OrderBy(record => record.Key, StringComparer.Ordinal) - .ToArray(); + : ReadLegacyPersistedGenerationRecords(manifest); return new CultPersistedStoreSnapshot { FormatVersion = manifest.FormatVersion, @@ -101,6 +99,28 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore } } + private CultPersistedRecord[] ReadLegacyPersistedGenerationRecords(CultPersistedStoreSnapshot manifest) + { + var records = manifest.Records + .Where(record => !string.IsNullOrWhiteSpace(record.Key)) + .ToList(); + _recordDirectory.Refresh(); + if (_recordDirectory.Exists) + { + records.AddRange(_recordDirectory.EnumerateFiles("*.msgpack") + .OrderBy(file => file.Name, StringComparer.Ordinal) + .Select(file => CultDocumentMessagePackSerialization.DeserializePersistedRecord( + ReadAllBytesShared(file.FullName)))); + } + + return records + .Where(record => !string.IsNullOrWhiteSpace(record.Key)) + .GroupBy(record => record.Key, StringComparer.Ordinal) + .Select(group => group.Last()) + .OrderBy(record => record.Key, StringComparer.Ordinal) + .ToArray(); + } + /// public override void PullAll() { diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 396b8a3a..cf24c47a 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -1446,13 +1446,22 @@ namespace GameCult.Caching /// public async Task PullAllBackingStoresAsync() { - foreach (var store in _backingStores) - { - store.PullAll(); - } + if (_ambientTransaction.Value != null) + throw new InvalidOperationException( + "CultCache hydration cannot run inside a mutation transaction; hydrate before opening the commit scope."); - _hasUnflushedMutations = _backingStores.Any(store => store.IsDirty); - await Task.CompletedTask; + await _transactionGate.WaitAsync().ConfigureAwait(false); + try + { + foreach (var store in _backingStores) + store.PullAll(); + + _hasUnflushedMutations = _backingStores.Any(store => store.IsDirty); + } + finally + { + _transactionGate.Release(); + } } /// @@ -1462,13 +1471,22 @@ namespace GameCult.Caching public async Task PullBackingStoreRecordsAsync(Func selector) { if (selector == null) throw new ArgumentNullException(nameof(selector)); - foreach (var store in _backingStores) - { - store.PullSelected(selector); - } + if (_ambientTransaction.Value != null) + throw new InvalidOperationException( + "CultCache hydration cannot run inside a mutation transaction; hydrate before opening the commit scope."); - _hasUnflushedMutations = _backingStores.Any(store => store.IsDirty); - await Task.CompletedTask; + await _transactionGate.WaitAsync().ConfigureAwait(false); + try + { + foreach (var store in _backingStores) + store.PullSelected(selector); + + _hasUnflushedMutations = _backingStores.Any(store => store.IsDirty); + } + finally + { + _transactionGate.Release(); + } } /// diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 20f05a3f..31b37b40 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -574,6 +574,10 @@ namespace GameCult.Caching.Tests File.WriteAllBytes(recordPath, CultDocumentMessagePackSerialization.SerializePersistedRecord(record)); var readStore = new DirectoryMessagePackBackingStore(filePath, recordsPath); + var rawGeneration = readStore.ReadPersistedGeneration(); + Assert.That(rawGeneration.Records.Select(value => value.Key), + Is.EqualTo(new[] { "record:stale" }), + "v1 split record pages remain part of the selected legacy generation"); var readCache = new CultCache(); readCache.AddBackingStore(readStore); await readCache.PullAllBackingStoresAsync(); @@ -1484,6 +1488,85 @@ namespace GameCult.Caching.Tests } } + [Test] + public async Task CultCache_Transaction_Rejects_Hydration_Inside_Its_Mutation_Scope() + { + 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(new CultRecordKey("transaction:external"))); + writer.FlushAllBackingStores(); + } + + using var reader = new CultCache(); + reader.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); + var observed = 0; + using var subscription = reader.Watch().Subscribe(_ => observed++); + + Assert.That( + async () => await reader.ExecuteTransactionAsync( + () => reader.PullAllBackingStoresAsync()), + Throws.TypeOf().With.Message.Contains("hydrate before")); + Assert.That(reader.Get(new CultRecordKey("transaction:external")), Is.Null); + Assert.That(observed, Is.Zero); + + await reader.PullAllBackingStoresAsync(); + Assert.That(reader.Get(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 manifest = Path.Combine(root, "state.cc"); + Directory.CreateDirectory(root); + try + { + using var cache = new CultCache(); + cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); + var staged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var transaction = cache.ExecuteTransactionAsync(async () => + { + await cache.UpsertAsync( + new NamedTestEntry { Name = "staged", Value = "committed" }, + new CultRecordHandle(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(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() { diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll index 60e4a9a7..529f4d3d 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb index e8107ba8..082f5c20 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll index ceebf413..b1fe72f8 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb index c488d61e..e89a1148 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll index fec736b2..5f30acb7 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb index bdfca059..7797e56a 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll index 902adced..d49d6f58 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb index 08d6e790..076a4456 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll index 476956c7..3de98925 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb index ab7c2c7d..c248f45a 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll index a3a77403..afe9b446 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll index 9328d164..2130b83e 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb index ffef7b76..8810690a 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb differ diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json index e5e5c57f..fd173021 100644 --- a/unity/org.gamecult.cultlib/package.json +++ b/unity/org.gamecult.cultlib/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.cultlib", "displayName": "CultLib", - "version": "1.0.55", + "version": "1.0.56", "unity": "2021.3", "description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.", "license": "MIT",