diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 454a8ed8..648ba09e 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -70,6 +70,37 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore /// public static string DefaultRecordDirectoryPath(string manifestPath) => manifestPath + ".records"; + /// + /// Reads one complete persisted generation without deserializing documents through a + /// runtime registry. Indexed manifests are resolved only through the pages they name; + /// orphaned pages from older generations are never included. + /// + public CultPersistedStoreSnapshot ReadPersistedGeneration() + { + lock (_mutationGate) + using (AcquireCommitLease()) + { + var manifest = ReadManifest(); + var indexed = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) || + string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) || + string.Equals(manifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal); + var records = indexed + ? manifest.Records + .OrderBy(record => record.Key, StringComparer.Ordinal) + .Select(record => ReadPersistedRecordPage(record, manifest.FormatVersion, out _)) + .ToArray() + : manifest.Records + .OrderBy(record => record.Key, StringComparer.Ordinal) + .ToArray(); + return new CultPersistedStoreSnapshot + { + FormatVersion = manifest.FormatVersion, + SchemaCatalog = manifest.SchemaCatalog.ToArray(), + Records = records + }; + } + } + /// public override void PullAll() { @@ -496,20 +527,11 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore { var started = tracePages ? Stopwatch.GetTimestamp() : 0L; var metadata = records[index]; - var path = _manifestUsesImmutablePages - ? ContentAddressedRecordPath(metadata) - : _manifestUsesMetadataPages - ? MetadataRecordPath(metadata) - : LegacyRecordPath(metadata.Key); - if (!File.Exists(path)) - throw new InvalidDataException($"Committed record page '{path}' is missing from the selected manifest generation."); - var pagePayload = ReadAllBytesShared(path); - if (_manifestUsesImmutablePages && - !HashPayload(pagePayload).SequenceEqual(metadata.Payload)) - throw new InvalidDataException($"Record page '{path}' does not match its committed content hash."); - var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(pagePayload); - if (!string.Equals(record.Key, metadata.Key, StringComparison.Ordinal)) - throw new InvalidDataException($"Record page '{path}' contains key '{record.Key}', expected '{metadata.Key}'."); + var record = ReadPersistedRecordPage( + metadata, + _manifestUsesImmutablePages, + _manifestUsesMetadataPages, + out var pagePayload); var catalog = ResolveLegacyUncataloguedRecordCatalog(record, catalogEntries); recordReports[index] = Registry.ResolvePersistedSchemaReport(record.SchemaId, catalog); storedRecords[index] = ToStoredDocument( @@ -797,6 +819,39 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore } } + private CultPersistedRecord ReadPersistedRecordPage( + CultPersistedRecord metadata, + string formatVersion, + out byte[] pagePayload) => + ReadPersistedRecordPage( + metadata, + string.Equals(formatVersion, IndexedFormatVersion, StringComparison.Ordinal), + string.Equals(formatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal), + out pagePayload); + + private CultPersistedRecord ReadPersistedRecordPage( + CultPersistedRecord metadata, + bool usesContentAddressedPages, + bool usesMetadataPages, + out byte[] pagePayload) + { + var path = usesContentAddressedPages + ? ContentAddressedRecordPath(metadata) + : usesMetadataPages + ? MetadataRecordPath(metadata) + : LegacyRecordPath(metadata.Key); + if (!File.Exists(path)) + throw new InvalidDataException($"Committed record page '{path}' is missing from the selected manifest generation."); + pagePayload = ReadAllBytesShared(path); + if (usesContentAddressedPages && + !HashPayload(pagePayload).SequenceEqual(metadata.Payload)) + throw new InvalidDataException($"Record page '{path}' does not match its committed content hash."); + var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(pagePayload); + if (!string.Equals(record.Key, metadata.Key, StringComparison.Ordinal)) + throw new InvalidDataException($"Record page '{path}' contains key '{record.Key}', expected '{metadata.Key}'."); + return record; + } + private static string HashKey(string key) { using var sha256 = SHA256.Create(); diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 1d1dab81..396b8a3a 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -1577,10 +1577,12 @@ namespace GameCult.Caching try { await stageAsync().ConfigureAwait(false); + transaction.Seal(); changes = CommitTransaction(transaction, soft); } finally { + transaction.Seal(); _ambientTransaction.Value = null; _transactionGate.Release(); } @@ -1904,7 +1906,7 @@ namespace GameCult.Caching CultCacheTransaction transaction, bool soft) { - var mutations = transaction.Mutations + var mutations = transaction.SnapshotForCommit() .OrderBy(pair => pair.Key, StringComparer.Ordinal) .ToArray(); if (mutations.Length == 0) @@ -1978,16 +1980,71 @@ namespace GameCult.Caching private sealed class CultCacheTransaction { + private readonly object _gate = new(); private readonly Dictionary _mutations = new(StringComparer.Ordinal); + private bool _sealed; - public IReadOnlyDictionary Mutations => _mutations; + public IReadOnlyDictionary Mutations + { + get + { + lock (_gate) + { + ThrowIfSealedForAmbientAccess(); + return new Dictionary(_mutations, StringComparer.Ordinal); + } + } + } - public void Stage(CultStoredDocument stored) => _mutations[stored.Key.Value] = stored; + public void Stage(CultStoredDocument stored) + { + lock (_gate) + { + ThrowIfSealedForAmbientAccess(); + _mutations[stored.Key.Value] = stored; + } + } - public void Delete(CultRecordKey key) => _mutations[key.Value] = null; + public void Delete(CultRecordKey key) + { + lock (_gate) + { + ThrowIfSealedForAmbientAccess(); + _mutations[key.Value] = null; + } + } - public bool TryGet(CultRecordKey key, out CultStoredDocument? stored) => - _mutations.TryGetValue(key.Value, out stored); + public bool TryGet(CultRecordKey key, out CultStoredDocument? stored) + { + lock (_gate) + { + ThrowIfSealedForAmbientAccess(); + return _mutations.TryGetValue(key.Value, out stored); + } + } + + public void Seal() + { + lock (_gate) + _sealed = true; + } + + public IReadOnlyDictionary SnapshotForCommit() + { + lock (_gate) + { + if (!_sealed) + throw new InvalidOperationException("A CultCache transaction must be sealed before commit."); + return new Dictionary(_mutations, StringComparer.Ordinal); + } + } + + private void ThrowIfSealedForAmbientAccess() + { + if (_sealed) + throw new InvalidOperationException( + "This CultCache transaction has already completed; escaped async work cannot read or mutate it."); + } } internal CultStoredDocument CreateStoredDocument(Type documentType, object document, CultRecordKey? key = null, string? storedAt = null) diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 39fe52ca..20f05a3f 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -1055,6 +1055,9 @@ namespace GameCult.Caching.Tests var compacted = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath)); Assert.That(compacted.SchemaCatalog, Is.Empty); Assert.That(compacted.Records, Is.Empty); + var selectedGeneration = store.ReadPersistedGeneration(); + Assert.That(selectedGeneration.Records, Is.Empty, + "a raw generation reader must not resurrect an orphaned page left by another process"); } finally { @@ -1432,6 +1435,55 @@ namespace GameCult.Caching.Tests } } + [Test] + public async Task CultCache_Transaction_Rejects_Escaped_Async_Writes_After_Commit() + { + var root = Path.Combine(Path.GetTempPath(), $"cultcache-escaped-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 committedKey = new CultRecordKey("transaction:committed-parent"); + var escapedKey = new CultRecordKey("transaction:escaped-child"); + var releaseChild = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task? escapedWrite = null; + + await cache.ExecuteTransactionAsync(async () => + { + await cache.UpsertAsync( + new NamedTestEntry { Name = "parent", Value = "committed" }, + new CultRecordHandle(committedKey)); + escapedWrite = Task.Run(async () => + { + await releaseChild.Task; + await cache.UpsertAsync( + new NamedTestEntry { Name = "child", Value = "must-fail" }, + new CultRecordHandle(escapedKey)); + }); + }); + + releaseChild.SetResult(true); + Assert.That(escapedWrite, Is.Not.Null); + Assert.That( + async () => await escapedWrite!, + Throws.TypeOf().With.Message.Contains("already completed")); + Assert.That(cache.Get(committedKey)?.Value, Is.EqualTo("committed")); + Assert.That(cache.Get(escapedKey), Is.Null); + + using var reopened = new CultCache(); + reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); + await reopened.PullAllBackingStoresAsync(); + Assert.That(reopened.Get(committedKey)?.Value, Is.EqualTo("committed")); + Assert.That(reopened.Get(escapedKey), Is.Null); + } + 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 7284604e..60e4a9a7 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 c881cae5..e8107ba8 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 7158570c..ceebf413 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 6b4ea8d7..c488d61e 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 b6116f32..fec736b2 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 4fdfbb00..bdfca059 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 a5468f11..902adced 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 2d70b0eb..08d6e790 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 78476742..476956c7 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 09bb1b13..ab7c2c7d 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 a01a7704..a3a77403 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 a68c628d..9328d164 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 e13f706d..ffef7b76 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 1b939c02..e5e5c57f 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.54", + "version": "1.0.55", "unity": "2021.3", "description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.", "license": "MIT",