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