Caching: public ReadStoreSnapshot and WriteRecord replace Mesh's InternalsVisibleTo reach
SingleFileBackingStore gains the cacheless single-file read and write CultMesh used through internals; CultMeshSingleFileDocuments is now a caller of the public API only, and the GameCult.Mesh InternalsVisibleTo grant (with the duplicate Tests/MessagePack grants) is deleted. Owns finding mesh-ivt-moved-not-removed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5baa02fa54
commit
26bf0986d0
5 changed files with 157 additions and 32 deletions
|
|
@ -4218,15 +4218,33 @@ namespace GameCult.Caching
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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, <paramref name="record"/> 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:
|
||||
/// <paramref name="holdsIds"/> 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 <paramref name="catalogEntry"/> does not declare, is refused and leaves the file as it was.
|
||||
/// </summary>
|
||||
internal void ApplyWriteSet(IReadOnlyCollection<CultStagedRecord> upserts, IReadOnlyCollection<string> removals) => Held(() =>
|
||||
/// <param name="record">The record to store.</param>
|
||||
/// <param name="catalogEntry">The schema catalog entry for the record's schema id.</param>
|
||||
/// <param name="holdsIds">Whether the record's payload holds element ids, or null when the caller cannot tell.</param>
|
||||
/// <param name="registry">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.</param>
|
||||
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache.</exception>
|
||||
/// <exception cref="CultStoreUnreadableException">The file at the path is not a store this runtime can read.</exception>
|
||||
public void WriteRecord(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds = null, CultDocumentRegistry? registry = null)
|
||||
{
|
||||
using (AcquireLock(wait: true))
|
||||
_ = ApplyWriteSet(ReadSnapshot(), upserts, removals, Array.Empty<string>());
|
||||
});
|
||||
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<string>(), Array.Empty<string>());
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <exception cref="CultStoreUnreadableException">The file is empty, truncated, or stamped with a format this runtime does not read.</exception>
|
||||
public CultPersistedStoreSnapshot? ReadStoreSnapshot() => Held(() => ReadSnapshot()?.Snapshot);
|
||||
|
||||
private byte[]? ReadDisk()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
|
||||
<InternalsVisibleTo Include="GameCult.Mesh" />
|
||||
<InternalsVisibleTo Include="GameCult.Caching.MessagePack" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
@ -14,7 +13,5 @@
|
|||
<PackageReference Include="MessagePack.Annotations" Version="3.1.7" />
|
||||
<PackageReference Include="R3" Version="1.3.0" />
|
||||
<PackageReference Include="ConcurrentHashSet " Version="1.3.0" />
|
||||
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
|
||||
<InternalsVisibleTo Include="GameCult.Caching.MessagePack" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -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<string>());
|
||||
catalogEntry,
|
||||
holdsIds,
|
||||
registry);
|
||||
}
|
||||
|
||||
private static SingleFileMessagePackBackingStore StoreAt(string path) => new SingleFileMessagePackBackingStore(path);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
96
tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs
Normal file
96
tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs
Normal file
|
|
@ -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<InvalidOperationException>(() => 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<CultStoreUnreadableException>(() => 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue