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",