diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index cb3d2b75..b7945177 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -4218,15 +4218,33 @@ namespace GameCult.Caching } /// - /// The one write of this store: under its lock, the durable store read there with the upserts written and the removals - /// and the upserts' keys taken out of what is copied, every other record copied forward as stored. CultMesh's - /// single-file document writes call this, with no cache attached. + /// Writes one record to the store file without a cache. Under the store's lock the file is read by the same reader a cache's + /// store uses, replaces the record at its key, and every other record and catalog entry is copied + /// forward as stored. The file is replaced atomically, and its format header is decided as a flush decides it: + /// true marks the store as holding element ids, false leaves it unmarked, and null (a payload + /// the caller cannot read) keeps whatever the file already says. A write that finds the file unreadable, or a record at + /// the key under a schema id does not declare, is refused and leaves the file as it was. /// - internal void ApplyWriteSet(IReadOnlyCollection upserts, IReadOnlyCollection removals) => Held(() => + /// The record to store. + /// The schema catalog entry for the record's schema id. + /// Whether the record's payload holds element ids, or null when the caller cannot tell. + /// The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read; omitted, it decides nothing for them. + /// The store is attached to a cache; write through the cache. + /// The file at the path is not a store this runtime can read. + public void WriteRecord(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds = null, CultDocumentRegistry? registry = null) { - using (AcquireLock(wait: true)) - _ = ApplyWriteSet(ReadSnapshot(), upserts, removals, Array.Empty()); - }); + if (record == null) throw new ArgumentNullException(nameof(record)); + if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry)); + if (Cache != null) + throw new InvalidOperationException("The store is attached to a cache, write through the cache."); + if (registry != null) + AttachRegistry(registry); + Held(() => + { + using (AcquireLock(wait: true)) + _ = ApplyWriteSet(ReadSnapshot(), new[] { new CultStagedRecord(record, catalogEntry, holdsIds) }, Array.Empty(), Array.Empty()); + }); + } // Flush, commit and Mesh's write all end here, with the durable store they read under the lock they hold. First, a key the write // names or checks whose durable record is under an id the writer does not declare refuses the write (RefuseUndeclaredOnDisk); a @@ -4506,9 +4524,13 @@ namespace GameCult.Caching } } - // CultMesh's single-file document helpers reach this store file only through this read and ApplyWriteSet, so they share - // its one reader, its lock and its atomic replace. - internal CultPersistedStoreSnapshot? ReadDurable() => Held(() => ReadSnapshot()?.Snapshot); + /// + /// Reads the store file once, under the store's gate, with the reader a cache's store opens with, and returns what is + /// durable at the path: the records, the schema catalog and the format stamp. Returns null when nothing is at the path. + /// CultMesh's single-file document reads are built on this, so they share the store's one reader and its stamp rule. + /// + /// The file is empty, truncated, or stamped with a format this runtime does not read. + public CultPersistedStoreSnapshot? ReadStoreSnapshot() => Held(() => ReadSnapshot()?.Snapshot); private byte[]? ReadDisk() { diff --git a/src/GameCult.Caching/GameCult.Caching.csproj b/src/GameCult.Caching/GameCult.Caching.csproj index dddad783..9819a079 100644 --- a/src/GameCult.Caching/GameCult.Caching.csproj +++ b/src/GameCult.Caching/GameCult.Caching.csproj @@ -6,7 +6,6 @@ - @@ -14,7 +13,5 @@ - - \ No newline at end of file diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs index 40480895..0d1dd4b4 100644 --- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs +++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs @@ -161,7 +161,7 @@ namespace GameCult.Mesh if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); if (string.IsNullOrWhiteSpace(expectedSchemaId)) throw new ArgumentException("Value must be non-empty.", nameof(expectedSchemaId)); - var snapshot = StoreAt(path).ReadDurable() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path); + var snapshot = StoreAt(path).ReadStoreSnapshot() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path); var record = snapshot.Records.SingleOrDefault(candidate => string.Equals(candidate.Key, key.Value, StringComparison.Ordinal)) ?? throw new InvalidDataException($"CultCache document '{path}' does not contain record '{key.Value}'."); @@ -192,7 +192,7 @@ namespace GameCult.Mesh if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); if (string.IsNullOrWhiteSpace(expectedSchemaId)) throw new ArgumentException("Value must be non-empty.", nameof(expectedSchemaId)); - var snapshot = StoreAt(path).ReadDurable() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path); + var snapshot = StoreAt(path).ReadStoreSnapshot() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path); if (snapshot.Records.Length != 1) { throw new InvalidDataException( @@ -244,24 +244,17 @@ namespace GameCult.Mesh // so a file already marked stays marked. A typed write attaches the registry its payload was encoded with, so the write // decides for copied records that registry can read; a raw payload attaches none and decides nothing. A store this runtime // cannot read refuses the write instead of being overwritten. - var store = StoreAt(path); - if (registry != null) - store.AttachRegistry(registry); - store.ApplyWriteSet( - new[] + StoreAt(path).WriteRecord( + new CultPersistedRecord { - new CultStagedRecord( - new CultPersistedRecord - { - Key = key.Value, - SchemaId = catalogEntry.SchemaId, - StoredAt = string.IsNullOrWhiteSpace(storedAt) ? DateTimeOffset.UtcNow.ToString("O") : storedAt!, - Payload = payload - }, - catalogEntry, - holdsIds) + Key = key.Value, + SchemaId = catalogEntry.SchemaId, + StoredAt = string.IsNullOrWhiteSpace(storedAt) ? DateTimeOffset.UtcNow.ToString("O") : storedAt!, + Payload = payload }, - Array.Empty()); + catalogEntry, + holdsIds, + registry); } private static SingleFileMessagePackBackingStore StoreAt(string path) => new SingleFileMessagePackBackingStore(path); diff --git a/tests/GameCult.Caching.ConsumerBuild/Program.cs b/tests/GameCult.Caching.ConsumerBuild/Program.cs index 417da1a6..1405cfd3 100644 --- a/tests/GameCult.Caching.ConsumerBuild/Program.cs +++ b/tests/GameCult.Caching.ConsumerBuild/Program.cs @@ -16,6 +16,23 @@ var committed = outside.Commit(batch => }); if (!committed) return 1; + +// A store file is written and read without a cache through the two public members: this project has no InternalsVisibleTo, so +// building it pins that ReadStoreSnapshot and WriteRecord are public API. +var storePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "consumer-build-" + System.Guid.NewGuid().ToString("N") + ".cc"); +try +{ + var entry = new CultSchemaCatalogEntry { SchemaId = "outside.written", SchemaName = "outside.written" }; + var store = new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(storePath); + store.WriteRecord(new CultPersistedRecord { Key = "r", SchemaId = entry.SchemaId, StoredAt = "t", Payload = new byte[] { 0x90 } }, entry); + var snapshot = store.ReadStoreSnapshot(); + if (snapshot == null || snapshot.Records.Length != 1 || snapshot.Records[0].Key != "r") + return 1; +} +finally +{ + System.IO.File.Delete(storePath); +} return 0; // Lists one record under an id no type claims, at version "v1", and commits only when the conditions hold over it. diff --git a/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs b/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs new file mode 100644 index 00000000..bc30b718 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs @@ -0,0 +1,96 @@ +#nullable enable +using System; +using System.IO; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // A single-file store file is read and written without a cache through ReadStoreSnapshot and WriteRecord: the same reader, lock, + // atomic replace and header rule a cache's store uses. CultMesh's single-file document helpers are built on these two members. + public class CachelessSingleFileTests + { + private static readonly CultSchemaCatalogEntry One = new() { SchemaId = "tests.cacheless.one", SchemaName = "tests.cacheless.one" }; + private static readonly CultSchemaCatalogEntry Two = new() { SchemaId = "tests.cacheless.two", SchemaName = "tests.cacheless.two" }; + private string _directory = ""; + private string _path = ""; + + [SetUp] + public void SetUp() + { + _directory = Path.Combine(Path.GetTempPath(), "cultlib-cacheless-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + _path = Path.Combine(_directory, "store.cc"); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, true); + } + + private static CultPersistedRecord Record(string key, CultSchemaCatalogEntry entry, params byte[] payload) => + new() { Key = key, SchemaId = entry.SchemaId, StoredAt = "2026-10-22T00:00:00.0000000+00:00", Payload = payload }; + + [Test] + public void WriteRecordKeepsAnotherWritersRecord() + { + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One); + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("b", Two, 2), Two); + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 3), One); + + var snapshot = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!; + + Assert.That(snapshot.Records, Has.Length.EqualTo(2)); + Assert.That(Array.Find(snapshot.Records, record => record.Key == "a")!.Payload, Is.EqualTo(new byte[] { 3 })); + Assert.That(Array.Find(snapshot.Records, record => record.Key == "b")!.Payload, Is.EqualTo(new byte[] { 2 })); + Assert.That(snapshot.SchemaCatalog, Has.Length.EqualTo(2)); + } + + [Test] + public void WriteRecordRefusesOnAStoreAttachedToACache() + { + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One); + var before = File.ReadAllBytes(_path); + var store = new SingleFileMessagePackBackingStore(_path); + using var cache = new CultCache(); + cache.AddBackingStore(store); + + var error = Assert.Throws(() => store.WriteRecord(Record("b", Two, 2), Two))!; + + Assert.That(error.Message, Does.Contain("write through the cache")); + Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before)); + } + + [Test] + public void ReadStoreSnapshotOfNothingIsNull() + { + Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot(), Is.Null); + } + + [Test] + public void ReadStoreSnapshotRefusesAV4Stamp() + { + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path)); + snapshot.FormatVersion = "cultcache.store.v4"; + File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + + var error = Assert.Throws(() => new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot())!; + + Assert.That(error.Message, Does.Contain("cultcache.store.v4")); + } + + [Test] + public void WriteRecordWithIdsMarksV3() + { + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One); + Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.FormatVersion, Is.Not.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("b", Two, 2), Two, holdsIds: true); + + Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + } + } +}