From debe6167160f4ff2d02d4d46c07d8b2ded4e2d7d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 14 Sep 2026 13:43:45 +0200 Subject: [PATCH] Open a directory store without creating anything Opening a directory store, read-only or writable, created the .records folder and its .commit.lock before reading a manifest that might not exist. Only a write creates them now: PullAll takes the commit lease with create: false, which opens an existing lock and returns no lease when there is none (no writer has ever committed there, so there is nothing to exclude). CommitBatch and PushAll create the folder and lock as before. Test DirectoryMessagePackBackingStore_Open_CreatesNothing_UntilAWrite: opening a missing store and a manifest-less .records folder, read-only and writable, leaves the filesystem unchanged; the first flush creates the manifest, the lock and one page. With the previous lease code restored the test fails on the first read-only open. Co-Authored-By: Claude Opus 5 --- .../DirectoryMessagePackBackingStore.cs | 23 +++-- .../Assets/Caching/Runtime.meta | 3 - .../Runtime/CultCacheInspectorAttributes.cs | 87 ------------------- .../CultCacheInspectorAttributes.cs.meta | 3 - .../Runtime/GameCult.Unity.Caching.asmdef | 14 --- .../GameCult.Unity.Caching.asmdef.meta | 7 -- .../BackingStoreTests.cs | 49 +++++++++++ 7 files changed, 66 insertions(+), 120 deletions(-) delete mode 100644 src/GameCult.Unity/Assets/Caching/Runtime.meta delete mode 100644 src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs delete mode 100644 src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs.meta delete mode 100644 src/GameCult.Unity/Assets/Caching/Runtime/GameCult.Unity.Caching.asmdef delete mode 100644 src/GameCult.Unity/Assets/Caching/Runtime/GameCult.Unity.Caching.asmdef.meta diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index b15bdbb1..5a98ff08 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -57,7 +57,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore var reports = new List(); var loaded = new Dictionary(StringComparer.Ordinal); CultSchemaCatalogEntry[] catalog; - using (AcquireCommitLease(wait: true)) + using (AcquireCommitLease(wait: true, create: false)) { // Only pages named by the manifest read under the lease are loaded; orphaned pages are never loaded. var manifest = ReadManifest(); @@ -131,7 +131,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore return Held(() => { Directory.CreateDirectory(_manifestFile.DirectoryName!); - using var commitLease = AcquireCommitLease(wait); + using var commitLease = AcquireCommitLease(wait, create: true); if (commitLease == null) return CultCommitOutcome.Contended; var manifest = ReadManifest(); @@ -186,7 +186,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore return; Directory.CreateDirectory(_manifestFile.DirectoryName!); - using var commitLease = AcquireCommitLease(wait: true); + using var commitLease = AcquireCommitLease(wait: true, create: true); WriteGeneration(ReadManifest()); }); } @@ -470,9 +470,12 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore return buffer.ToArray(); } - private FileStream? AcquireCommitLease(bool wait) + // Only a writer creates the records folder and the lock. A reader opens an existing lock; with none, no writer has ever + // committed here, so there is nothing to exclude and opening the store creates nothing. + private FileStream? AcquireCommitLease(bool wait, bool create) { - Directory.CreateDirectory(_recordDirectory.FullName); + if (create) + Directory.CreateDirectory(_recordDirectory.FullName); var lockPath = Path.Combine(_recordDirectory.FullName, ".commit.lock"); var started = Stopwatch.StartNew(); while (true) @@ -481,12 +484,20 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore { return new FileStream( lockPath, - FileMode.OpenOrCreate, + create ? FileMode.OpenOrCreate : FileMode.Open, FileAccess.ReadWrite, FileShare.None, bufferSize: 1, FileOptions.WriteThrough); } + catch (FileNotFoundException) when (!create) + { + return null; + } + catch (DirectoryNotFoundException) when (!create) + { + return null; + } catch (IOException) when (!wait) { return null; diff --git a/src/GameCult.Unity/Assets/Caching/Runtime.meta b/src/GameCult.Unity/Assets/Caching/Runtime.meta deleted file mode 100644 index 5142c828..00000000 --- a/src/GameCult.Unity/Assets/Caching/Runtime.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 5a7d06cb8f1e491785b3ae24034f1121 -timeCreated: 1779630000 diff --git a/src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs b/src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs deleted file mode 100644 index 1005322c..00000000 --- a/src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System; - -namespace GameCult.Unity.Caching -{ - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Class, Inherited = true)] - public sealed class CultInspectorLabelAttribute : Attribute - { - public CultInspectorLabelAttribute(string label) - { - Label = label ?? string.Empty; - } - - public string Label { get; } - } - - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] - public sealed class CultInspectorHiddenAttribute : Attribute - { - } - - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] - public sealed class CultInspectorReadOnlyAttribute : Attribute - { - } - - // Lower values appear first; members without it keep their key order. - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] - public sealed class CultInspectorOrderAttribute : Attribute - { - public CultInspectorOrderAttribute(int order) - { - Order = order; - } - - public int Order { get; } - } - - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] - public sealed class CultInspectorTextAreaAttribute : Attribute - { - public CultInspectorTextAreaAttribute(int minLines = 3, int maxLines = 12) - { - MinLines = minLines; - MaxLines = maxLines; - } - - public int MinLines { get; } - public int MaxLines { get; } - } - - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] - public sealed class CultInspectorRangeAttribute : Attribute - { - public CultInspectorRangeAttribute(float min, float max) - { - Min = min; - Max = max; - } - - public float Min { get; } - public float Max { get; } - } - - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] - public sealed class CultInspectorAssetPathAttribute : Attribute - { - public CultInspectorAssetPathAttribute(Type assetType = null) - { - AssetType = assetType; - } - - public Type AssetType { get; } - } - - // Marks an editor class implementing ICultInspectorDrawer as the drawer for every member of MemberType, - // or of any closed form when MemberType is an open generic definition. It wins over the built-in drawers. - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class CultInspectorDrawerAttribute : Attribute - { - public CultInspectorDrawerAttribute(Type memberType) - { - MemberType = memberType ?? throw new ArgumentNullException(nameof(memberType)); - } - - public Type MemberType { get; } - } -} diff --git a/src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs.meta b/src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs.meta deleted file mode 100644 index ecc92c68..00000000 --- a/src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 8871d3eb346242e3b0acfd6da5108676 -timeCreated: 1779630000 diff --git a/src/GameCult.Unity/Assets/Caching/Runtime/GameCult.Unity.Caching.asmdef b/src/GameCult.Unity/Assets/Caching/Runtime/GameCult.Unity.Caching.asmdef deleted file mode 100644 index 6e7e12a6..00000000 --- a/src/GameCult.Unity/Assets/Caching/Runtime/GameCult.Unity.Caching.asmdef +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "GameCult.Unity.Caching", - "rootNamespace": "GameCult.Unity.Caching", - "references": [], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": true -} diff --git a/src/GameCult.Unity/Assets/Caching/Runtime/GameCult.Unity.Caching.asmdef.meta b/src/GameCult.Unity/Assets/Caching/Runtime/GameCult.Unity.Caching.asmdef.meta deleted file mode 100644 index 00ae3475..00000000 --- a/src/GameCult.Unity/Assets/Caching/Runtime/GameCult.Unity.Caching.asmdef.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ab6636c1402c4c579df9c07101542f13 -AssemblyDefinitionImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 98ce654e..be868959 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -284,6 +284,55 @@ namespace GameCult.Caching.Tests } } + [Test] + public async Task DirectoryMessagePackBackingStore_Open_CreatesNothing_UntilAWrite() + { + var root = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + var filePath = Path.Combine(root, "store.cc"); + var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); + + try + { + foreach (var readOnly in new[] { true, false }) + { + using (await CultCacheMessagePack.OpenAsync(filePath, new CultCacheOpenOptions { UseDirectoryStore = true, ReadOnly = readOnly })) + { + } + + Assert.That(Directory.GetFileSystemEntries(root), Is.Empty, + $"opening a missing directory store (read-only {readOnly}) created files"); + } + + Directory.CreateDirectory(recordsPath); + foreach (var readOnly in new[] { true, false }) + { + using (var cache = await CultCacheMessagePack.OpenAsync(filePath, new CultCacheOpenOptions { UseDirectoryStore = true, ReadOnly = readOnly })) + { + Assert.That(cache.AllStoredDocuments, Is.Empty); + } + + Assert.That(Directory.GetFileSystemEntries(recordsPath), Is.Empty, + $"opening a manifest-less directory store (read-only {readOnly}) created a lock"); + Assert.That(File.Exists(filePath), Is.False); + } + + using (var writer = await CultCacheMessagePack.OpenAsync(filePath, new CultCacheOpenOptions { UseDirectoryStore = true })) + { + await writer.UpsertAsync(new NamedTestEntry { Name = "first-write", Value = "creates the store" }); + await writer.FlushAsync(); + } + + Assert.That(File.Exists(filePath), Is.True); + Assert.That(File.Exists(Path.Combine(recordsPath, ".commit.lock")), Is.True); + Assert.That(Directory.GetFiles(recordsPath, "*.msgpack"), Has.Length.EqualTo(1)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + [Test] public async Task CultCacheMessagePack_OpenAsync_HydratesPersistedGlobal() {