CultDocumentRegistry.Owns replaces Declares as the one answer to whether a writer may write, relabel or remove a record under a schema id: the id is declared, or the type that reads it reads it whole (equal content hash, or compatible shapes with no persisted slot ignored). Staging asks the declared half or CultStoredDocument.StoredSchemaReadWhole; the apply halves (single file, directory, cacheless CultMesh) ask Owns over the durable catalog, once per distinct id. A record read only by dropping slots stays read-only until its id is declared compatible. Not built or run at this commit: verified on Yggdrasil at the cut's head. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1425 lines
76 KiB
C#
1425 lines
76 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Reflection.Emit;
|
|
using System.Text.RegularExpressions;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using MessagePack;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Caching.Tests
|
|
{
|
|
// C2a of docs/document-variants-cut.md: every object element of a list has an id, minted by the cache.
|
|
public class ElementIdTests
|
|
{
|
|
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
|
|
|
|
private static readonly ModuleBuilder Rejected = AssemblyBuilder
|
|
.DefineDynamicAssembly(new AssemblyName("RejectedElementShapes"), AssemblyBuilderAccess.Run)
|
|
.DefineDynamicModule("RejectedElementShapes");
|
|
|
|
private string _directory = string.Empty;
|
|
|
|
[SetUp]
|
|
public void CreateDirectory()
|
|
{
|
|
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-element-ids-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(_directory);
|
|
}
|
|
|
|
[TearDown]
|
|
public void DeleteDirectory()
|
|
{
|
|
if (Directory.Exists(_directory))
|
|
Directory.Delete(_directory, recursive: true);
|
|
}
|
|
|
|
private string PathOf(string name) => Path.Combine(_directory, name);
|
|
|
|
private static CultCache Open(string path) =>
|
|
CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
|
|
|
|
private static IdDeck Deck(string name, int reels = 2) => new()
|
|
{
|
|
Name = name,
|
|
Reels = Enumerable.Range(0, reels).Select(_ => new IdReel { Label = "reel", Marks = { new IdMark { Text = "m" }, new IdMark { Text = "m" } } }).ToList()
|
|
};
|
|
|
|
private static string[] AllIds(IdDeck deck) =>
|
|
deck.Reels.Select(reel => reel.Id)
|
|
.Concat(deck.Reels.SelectMany(reel => reel.Marks.Select(mark => mark.Id)))
|
|
.Concat(deck.Moves.Select(move => move.Id))
|
|
.ToArray();
|
|
|
|
// A store written before ids existed: the current catalog and schema id, payloads without the id slots.
|
|
private static void WritePreIdStore(string path, string? keepKey, params (string Key, string Name, int Reels)[] records)
|
|
{
|
|
using (var seed = Open(path))
|
|
{
|
|
seed.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("seed"), new CultRecordKey("seed")));
|
|
if (keepKey != null)
|
|
seed.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("kept"), new CultRecordKey(keepKey)));
|
|
}
|
|
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
var schemaId = snapshot.Records.First().SchemaId;
|
|
var kept = snapshot.Records.Where(record => record.Key == keepKey);
|
|
snapshot.Records = kept.Concat(records.Select(record => new CultPersistedRecord
|
|
{
|
|
Key = record.Key,
|
|
SchemaId = schemaId,
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
Payload = MessagePackSerializer.Serialize(new OldDeck
|
|
{
|
|
Name = record.Name,
|
|
Reels = Enumerable.Range(0, record.Reels).Select(_ => new OldReel { Label = "reel", Marks = { new OldMark { Text = "m" }, new OldMark { Text = "m" } } }).ToList()
|
|
})
|
|
})).ToArray();
|
|
// What a store from before ids declared.
|
|
snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV1;
|
|
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
|
}
|
|
|
|
// An element id is an ordinary keyed string, so a record's bytes do not say whether it holds one: only the store header does.
|
|
// A cache whose type cannot see the ids (a later shape with no reels, declared compatible or resolved by name) that copies the
|
|
// record forward leaves the store marked, the record untouched, and its ids readable by the type that wrote them.
|
|
[Test]
|
|
public void ANarrowerTypeFlushingAnotherRecordKeepsAMarkedStoreMarked([Values] bool declared, [Values] bool directory)
|
|
{
|
|
var path = PathOf("narrow.cc");
|
|
var deckSchemaId = Registry.GetRequired(typeof(IdDeck)).SchemaId;
|
|
var deck = Deck("r");
|
|
using (var owner = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory }))
|
|
owner.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("r")));
|
|
var marked = directory ? "cultcache.store.v5.directory-content-addressed-pages" : CultPersistedStoreSnapshot.FormatV3;
|
|
var before = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
Assert.That(before.FormatVersion, Is.EqualTo(marked));
|
|
|
|
var narrow = EmittedDocumentTypes.Emit(
|
|
"NarrowDeck" + (declared ? "Declared" : "ByName"),
|
|
"tests.element_id_deck",
|
|
declared ? "tests.element_id_deck.v9" : "tests.element_id_deck.v8",
|
|
new[] { new EmittedDocumentTypes.Field("Name", typeof(string), 0, IsName: true) },
|
|
declared ? new[] { deckSchemaId } : null);
|
|
using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = CultDocumentRegistry.ForTypes(new[] { narrow }), UseDirectoryStore = directory }))
|
|
{
|
|
Assert.That(cache.Get(new CultRecordKey("r")), Is.Not.Null, "the narrower type reads the record it will copy");
|
|
cache.UpsertAsync(narrow, EmittedDocumentTypes.New(narrow, ("Name", "y")), new CultRecordKey("y")).GetAwaiter().GetResult();
|
|
cache.FlushAllBackingStores();
|
|
}
|
|
|
|
var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
Assert.That(after.FormatVersion, Is.EqualTo(marked));
|
|
var kept = before.Records.Single(record => record.Key == "r");
|
|
var copied = after.Records.Single(record => record.Key == "r");
|
|
Assert.That($"{copied.SchemaId}|{copied.StoredAt}", Is.EqualTo($"{kept.SchemaId}|{kept.StoredAt}"));
|
|
using var reader = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory });
|
|
Assert.That(reader.Get<IdDeck>(new CultRecordKey("r"))!.Reels.Select(reel => reel.Id), Is.EqualTo(deck.Reels.Select(reel => reel.Id)));
|
|
}
|
|
|
|
private static CultPersistedRecord DiskRecord(string path, string key) =>
|
|
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Single(record => record.Key == key);
|
|
|
|
[Test]
|
|
public void ATypeWhoseListElementLacksAnIdIsRefusedByName()
|
|
{
|
|
var element = Rejected.DefineType("BareElement", TypeAttributes.Public);
|
|
element.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false }));
|
|
element.DefineField("Label", typeof(string), FieldAttributes.Public)
|
|
.SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { 0 }));
|
|
var elementType = element.CreateType()!;
|
|
|
|
var document = Rejected.DefineType("BareDocument", TypeAttributes.Public);
|
|
document.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, new object[] { "shape.bare", "shape.bare.v1" }));
|
|
document.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false }));
|
|
document.DefineField("Items", typeof(List<>).MakeGenericType(elementType), FieldAttributes.Public)
|
|
.SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { 0 }));
|
|
|
|
var refusal = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { document.CreateType()! }))!;
|
|
Assert.That(refusal.Message, Does.Contain("BareDocument").And.Contain("BareElement").And.Contain("Items").And.Contain("[CultElementId]"));
|
|
}
|
|
|
|
[Test]
|
|
public void AWriteMintsAnIdForEveryElementAndTheyRoundTrip()
|
|
{
|
|
var path = PathOf("deck.cc");
|
|
var deck = Deck("d");
|
|
deck.Moves.Add(new IdSlash { Reach = 1 });
|
|
using (var writer = Open(path))
|
|
writer.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("d")));
|
|
var written = AllIds(deck);
|
|
Assert.That(written, Has.Length.EqualTo(7));
|
|
Assert.That(written, Has.All.Matches<string>(id => Regex.IsMatch(id, "^[0-9a-f]{12}$")));
|
|
|
|
using var reader = Open(path);
|
|
Assert.That(AllIds(reader.Get<IdDeck>(new CultRecordKey("d"))!), Is.EqualTo(written));
|
|
}
|
|
|
|
[Test]
|
|
public void TwoLookAlikeUnionElementsGetDistinctIds()
|
|
{
|
|
var deck = new IdDeck { Name = "u", Moves = { new IdSlash { Reach = 3 }, new IdSlash { Reach = 3 }, new IdStab { Depth = 3 } } };
|
|
using var cache = Open(PathOf("u.cc"));
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("u")));
|
|
Assert.That(deck.Moves.Select(move => move.Id).Distinct().Count(), Is.EqualTo(3));
|
|
}
|
|
|
|
[Test]
|
|
public void ADuplicateIdInOneListIsRefusedAndTheSameIdInTwoListsIsNot()
|
|
{
|
|
using var cache = Open(PathOf("dup.cc"));
|
|
var dup = Deck("dup");
|
|
dup.Reels[0].Id = "aaaaaaaaaaaa";
|
|
dup.Reels[1].Id = "aaaaaaaaaaaa";
|
|
var refusal = Assert.Throws<CultElementIdException>(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), dup, new CultRecordKey("dup"))))!;
|
|
Assert.That(refusal.Message, Does.Contain("aaaaaaaaaaaa"));
|
|
Assert.That(cache.Get<IdDeck>(new CultRecordKey("dup")), Is.Null);
|
|
|
|
var fine = Deck("fine");
|
|
fine.Reels[0].Id = "aaaaaaaaaaaa";
|
|
fine.Reels[0].Marks[0].Id = "aaaaaaaaaaaa";
|
|
Assert.That(cache.Commit(batch => batch.Upsert(typeof(IdDeck), fine, new CultRecordKey("fine"))), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void APreIdStoreLoadsAndItsIdsAreMintedExactlyOnce()
|
|
{
|
|
var path = PathOf("old.cc");
|
|
WritePreIdStore(path, null, ("a", "a", 2), ("b", "b", 1));
|
|
var before = DiskRecord(path, "a").Payload;
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "the fixture is a store from before ids");
|
|
|
|
string[] first;
|
|
using (var cache = Open(path))
|
|
{
|
|
first = AllIds(cache.Get<IdDeck>(new CultRecordKey("a"))!);
|
|
Assert.That(first, Has.Length.EqualTo(6));
|
|
Assert.That(first, Has.All.Not.Empty);
|
|
Assert.That(first.Distinct().Count(), Is.EqualTo(first.Length));
|
|
Assert.That(DiskRecord(path, "a").Payload, Is.EqualTo(before), "loading mints in memory and writes nothing");
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("other"), new CultRecordKey("other")));
|
|
Assert.That(DiskRecord(path, "a").Payload, Is.EqualTo(before), "a write of another record leaves a's bytes as they are: its minted ids stay in memory");
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "and the write declares that the store holds ids: the record it wrote holds some");
|
|
}
|
|
|
|
using (var again = Open(path))
|
|
Assert.That(AllIds(again.Get<IdDeck>(new CultRecordKey("a"))!), Is.EqualTo(first), "a reload is identical to what was read");
|
|
|
|
using (var writer = Open(path))
|
|
{
|
|
var deck = writer.Get<IdDeck>(new CultRecordKey("a"))!;
|
|
deck.Name = "a2";
|
|
writer.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("a")));
|
|
writer.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("a")));
|
|
}
|
|
|
|
using var last = Open(path);
|
|
var reread = last.Get<IdDeck>(new CultRecordKey("a"))!;
|
|
Assert.That(reread.Name, Is.EqualTo("a2"));
|
|
Assert.That(AllIds(reread), Is.EqualTo(first), "the ids minted at load are the ids written, and stay through repeated writes");
|
|
}
|
|
|
|
[Test]
|
|
public void MintElementIdsRewritesEveryPreIdRecordAndNothingElse()
|
|
{
|
|
var path = PathOf("mint.cc");
|
|
WritePreIdStore(path, "new", ("old1", "o1", 1), ("old2", "o2", 3));
|
|
string[] ids1, ids2;
|
|
var newRecord = DiskRecord(path, "new");
|
|
|
|
using (var cache = Open(path))
|
|
{
|
|
ids1 = AllIds(cache.Get<IdDeck>(new CultRecordKey("old1"))!);
|
|
ids2 = AllIds(cache.Get<IdDeck>(new CultRecordKey("old2"))!);
|
|
Assert.That(cache.MintElementIds(), Is.EqualTo(2));
|
|
Assert.That(cache.MintElementIds(), Is.EqualTo(0), "the second run finds nothing to do");
|
|
}
|
|
|
|
Assert.That(DiskRecord(path, "new").Payload, Is.EqualTo(newRecord.Payload));
|
|
Assert.That(DiskRecord(path, "new").StoredAt, Is.EqualTo(newRecord.StoredAt), "a record that already had ids is not touched");
|
|
Assert.That(AllIds(MessagePackSerializer.Deserialize<IdDeck>(DiskRecord(path, "old1").Payload)), Is.EqualTo(ids1), "the ids are on disk now");
|
|
using var reader = Open(path);
|
|
Assert.That(AllIds(reader.Get<IdDeck>(new CultRecordKey("old1"))!), Is.EqualTo(ids1));
|
|
Assert.That(AllIds(reader.Get<IdDeck>(new CultRecordKey("old2"))!), Is.EqualTo(ids2));
|
|
Assert.That(reader.MintElementIds(), Is.EqualTo(0));
|
|
}
|
|
|
|
// A pre-id record this cache reads whole through the catalog's schema name is owned: the rewrite that persists its minted
|
|
// ids is accepted and stores it under this type's id.
|
|
[Test]
|
|
public void MintElementIdsRewritesAPreIdRecordUnderAnotherRuntimesIdThatItReadsWhole()
|
|
{
|
|
const string otherRuntimeId = "other.runtime.id_deck";
|
|
var path = PathOf("mint-drift.cc");
|
|
WritePreIdStore(path, null, ("a", "a", 1));
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
var entry = snapshot.SchemaCatalog.Single();
|
|
entry.SchemaId = otherRuntimeId;
|
|
entry.ContentHash = otherRuntimeId;
|
|
entry.CompatibleSchemaIds = new[] { otherRuntimeId };
|
|
snapshot.Records.Single().SchemaId = otherRuntimeId;
|
|
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
|
|
|
using (var cache = Open(path))
|
|
{
|
|
Assert.That(cache.MintElementIds(), Is.EqualTo(1));
|
|
Assert.That(cache.GetStored(new CultRecordKey("a"))!.StoredSchemaId, Is.Not.EqualTo(otherRuntimeId), "the rewrite restamped it");
|
|
}
|
|
|
|
Assert.That(DiskRecord(path, "a").SchemaId, Is.Not.EqualTo(otherRuntimeId));
|
|
}
|
|
|
|
// A pre-id record this cache reads only by dropping a persisted slot is read-only, so the rewrite that would persist its
|
|
// minted ids is refused typed, naming it, and the store is left byte for byte.
|
|
[Test]
|
|
public void MintElementIdsIsRefusedByAReadOnlyRecordWithMintedIds()
|
|
{
|
|
const string otherRuntimeId = "other.runtime.id_deck";
|
|
var path = PathOf("mint-read-only.cc");
|
|
WritePreIdStore(path, null, ("a", "a", 1));
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
var entry = snapshot.SchemaCatalog.Single();
|
|
entry.SchemaId = otherRuntimeId;
|
|
entry.ContentHash = otherRuntimeId;
|
|
entry.CompatibleSchemaIds = new[] { otherRuntimeId };
|
|
entry.Members = entry.Members.Append(new CultSchemaMemberCatalogEntry { Slot = 99, MemberName = "Dropped", TypeName = "System.String" }).ToArray();
|
|
snapshot.Records.Single().SchemaId = otherRuntimeId;
|
|
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
|
var before = File.ReadAllBytes(path);
|
|
|
|
using (var cache = Open(path))
|
|
{
|
|
Assert.That(AllIds(cache.Get<IdDeck>(new CultRecordKey("a"))!), Has.All.Not.Empty, "the load minted ids in memory");
|
|
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.MintElementIds())!;
|
|
Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo(("a", otherRuntimeId)));
|
|
cache.FlushAsync().GetAwaiter().GetResult();
|
|
}
|
|
|
|
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
|
|
}
|
|
|
|
[Test]
|
|
public void TheByteCostOfAnIdIsMeasured()
|
|
{
|
|
var ids = new IdDeck { Name = "m" };
|
|
var old = new OldDeck { Name = "m" };
|
|
for (var index = 0; index < 100; index++)
|
|
{
|
|
ids.Reels.Add(new IdReel { Label = "reel", Id = "0123456789ab" });
|
|
old.Reels.Add(new OldReel { Label = "reel" });
|
|
}
|
|
|
|
// IdDeck also carries the empty Moves list at slot 2: one byte, once.
|
|
var perElement = (MessagePackSerializer.Serialize(ids).Length - MessagePackSerializer.Serialize(old).Length - 1) / 100.0;
|
|
TestContext.Out.WriteLine($"element id cost: {perElement} bytes per element (12-char id in the next free slot)");
|
|
Assert.That(perElement, Is.EqualTo(13.0), "1 byte of string header and 12 of id; the element's array header does not grow");
|
|
}
|
|
|
|
[Test]
|
|
public void ARemovedPreIdRecordIsNotRewrittenByMintElementIds()
|
|
{
|
|
var path = PathOf("removed.cc");
|
|
WritePreIdStore(path, null, ("a", "a", 1), ("b", "b", 1));
|
|
using var cache = Open(path);
|
|
// Conditional, so the file is merged onto and b's ids stay in memory only.
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Expect(new CultRecordKey("elsewhere"), null);
|
|
batch.Remove(new CultRecordKey("a"));
|
|
});
|
|
Assert.That(cache.MintElementIds(), Is.EqualTo(1), "only the survivor is listed; the removed record is not resurrected");
|
|
Assert.That(cache.Get<IdDeck>(new CultRecordKey("a")), Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public void ADerivedIdIsTheSourceTextNeverRandomAndItsDuplicatesAreRefused()
|
|
{
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SpanDoc) });
|
|
SpanDoc Make() => new() { Name = "s", Spans = { new IdSpan { Offset = 0, Size = 4 }, new IdSpan { Offset = 262144, Size = 4 } } };
|
|
var first = Make();
|
|
var second = Make();
|
|
using (var cache = CultCacheMessagePack.Create(PathOf("span1.cc"), new CultCacheOpenOptions { Registry = registry }))
|
|
cache.Commit(batch => batch.Upsert(typeof(SpanDoc), first, new CultRecordKey("s")));
|
|
using (var cache = CultCacheMessagePack.Create(PathOf("span2.cc"), new CultCacheOpenOptions { Registry = registry }))
|
|
cache.Commit(batch => batch.Upsert(typeof(SpanDoc), second, new CultRecordKey("s")));
|
|
Assert.That(first.Spans.Select(span => span.Id), Is.EqualTo(new[] { "0", "262144" }));
|
|
Assert.That(MessagePackSerializer.Serialize(second), Is.EqualTo(MessagePackSerializer.Serialize(first)), "the same content encodes to the same bytes");
|
|
|
|
var twice = new SpanDoc { Name = "t", Spans = { new IdSpan { Offset = 7 }, new IdSpan { Offset = 7 } } };
|
|
using var refusing = CultCacheMessagePack.Create(PathOf("span3.cc"), new CultCacheOpenOptions { Registry = registry });
|
|
var refusal = Assert.Throws<CultElementIdException>(() => refusing.Commit(batch => batch.Upsert(typeof(SpanDoc), twice, new CultRecordKey("t"))))!;
|
|
Assert.That(refusal.Message, Does.Contain("'7'"));
|
|
}
|
|
|
|
// ---- C2a fix batch: the marker, refusals at load, override values, atomic minting ----
|
|
|
|
private const string HexA = "aaaaaaaaaaaa";
|
|
|
|
private static CultCache OpenWith(string path, bool directory, params Type[] types) =>
|
|
CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = CultDocumentRegistry.ForTypes(types), UseDirectoryStore = directory });
|
|
|
|
private static string HeaderOf(string path) =>
|
|
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion;
|
|
|
|
private static void UpsertPlain(CultCache cache, string key) =>
|
|
cache.Commit(batch => batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = key }, new CultRecordKey(key)));
|
|
|
|
private static CultElementIdException Refusal(Exception error)
|
|
{
|
|
for (var inner = error; inner != null; inner = inner.InnerException!)
|
|
{
|
|
if (inner is CultElementIdException typed)
|
|
return typed;
|
|
}
|
|
|
|
throw new AssertionException("no CultElementIdException in the chain: " + error);
|
|
}
|
|
|
|
private static byte[] OldShapedReels(string label) =>
|
|
MessagePackSerializer.Serialize(new List<OldReel> { new() { Label = label, Marks = { new OldMark { Text = "x" }, new OldMark { Text = "y" } } } });
|
|
|
|
// A holder the walk does not own (ImmutableArray here) is one whole value: its elements carry no element ids, so it never
|
|
// marks the store, and a variant replaces it whole (q3 amended 2026-10-16).
|
|
[Test]
|
|
public void AWholeListHolderNeverMarksTheStoreAndAVariantReplacesItWhole()
|
|
{
|
|
Assert.That(CultElementIds.CanHoldIds(typeof(IdWholeHolder)), Is.False);
|
|
var path = PathOf("whole-holder.cc");
|
|
using (var cache = OpenWith(path, false, typeof(IdWholeHolder)))
|
|
{
|
|
cache.Commit(batch => batch.Upsert(typeof(IdWholeHolder),
|
|
new IdWholeHolder { Name = "base", Reels = ImmutableArray.Create(new IdReel { Label = "set", Id = HexA }, new IdReel { Label = "unset" }) },
|
|
new CultRecordKey("base")));
|
|
}
|
|
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "ids inside a whole value mark nothing");
|
|
|
|
using (var fresh = OpenWith(path, false, typeof(IdWholeHolder)))
|
|
{
|
|
fresh.Commit(batch => batch.Upsert(typeof(IdWholeHolder), new IdWholeHolder { Name = "other" }, new CultRecordKey("other")));
|
|
var back = fresh.Get<IdWholeHolder>(new CultRecordKey("base"))!;
|
|
Assert.That(back.Reels.Select(reel => reel.Id), Is.EqualTo(new[] { HexA, "" }), "set ids are plain values, unset ids stay unset");
|
|
}
|
|
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"));
|
|
using (var cache = OpenWith(path, false, typeof(IdWholeHolder)))
|
|
{
|
|
var replacement = ImmutableArray.Create(new IdReel { Label = "v", Id = "eeeeeeeeeeee" });
|
|
cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"),
|
|
new[] { cache.Override<IdWholeHolder>(nameof(IdWholeHolder.Name), "v"), cache.Override<IdWholeHolder>(nameof(IdWholeHolder.Reels), replacement) }));
|
|
Assert.That(cache.Get<IdWholeHolder>(new CultRecordKey("v"))!.Reels.Select(reel => reel.Id), Is.EqualTo(new[] { "eeeeeeeeeeee" }), "replaced whole");
|
|
Assert.That(cache.Get<IdWholeHolder>(new CultRecordKey("base"))!.Reels, Has.Length.EqualTo(2), "the base is untouched");
|
|
}
|
|
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "a variant makes v2, and nothing in it holds element ids");
|
|
using var again = OpenWith(path, false, typeof(IdWholeHolder));
|
|
Assert.That(again.Get<IdWholeHolder>(new CultRecordKey("v"))!.Reels.Single().Id, Is.EqualTo("eeeeeeeeeeee"));
|
|
|
|
var step = new CultVariantOverride(CultOverrideOp.Set, new[] { new CultPathStep(1, HexA) }, string.Empty, MessagePackSerializer.Serialize("x"));
|
|
var refused = Assert.Catch<Exception>(() => again.Commit(batch => batch.UpsertVariant(new CultRecordKey("p"), new CultRecordKey("base"), new[] { again.Override<IdWholeHolder>(nameof(IdWholeHolder.Name), "p"), step })))!;
|
|
Assert.That(refused.ToString(), Does.Contain("top-level Set overrides only"));
|
|
}
|
|
|
|
[Test]
|
|
public void AStoreThatCanHoldElementIdsCarriesTheV3MarkerAndOneThatCannotStaysV1()
|
|
{
|
|
var ids = PathOf("ids.cc");
|
|
using (var cache = OpenWith(ids, false, typeof(IdDeck)))
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
|
Assert.That(HeaderOf(ids), Is.EqualTo("cultcache.store.v3"));
|
|
|
|
var plain = PathOf("plain.cc");
|
|
using (var cache = OpenWith(plain, false, typeof(PreCut2FixtureItem)))
|
|
UpsertPlain(cache, "a");
|
|
Assert.That(HeaderOf(plain), Is.EqualTo("cultcache.store.v1"));
|
|
|
|
using var reread = OpenWith(ids, false, typeof(IdDeck));
|
|
Assert.That(reread.Get<IdDeck>(new CultRecordKey("d")), Is.Not.Null, "a v3 store reads");
|
|
}
|
|
|
|
[Test]
|
|
public void AStagedWriteThatIsFlushedCarriesTheMarkerAndLeavesLoadMintedIdsToMintElementIds()
|
|
{
|
|
var path = PathOf("flush.cc");
|
|
WritePreIdStore(path, null, ("a", "a", 1));
|
|
using (var cache = Open(path))
|
|
{
|
|
cache.UpsertAsync(typeof(IdDeck), Deck("staged"), new CultRecordKey("staged")).GetAwaiter().GetResult();
|
|
cache.FlushAllBackingStores();
|
|
Assert.That(cache.MintElementIds(), Is.EqualTo(1), "the flush wrote only the staged record: a's minted ids are still in memory");
|
|
}
|
|
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"));
|
|
Assert.That(AllIds(MessagePackSerializer.Deserialize<IdDeck>(DiskRecord(path, "a").Payload)), Has.All.Not.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public void AnUnconditionalWriteOfAPlainRecordKeepsTheMarkerWhileTheStoreHoldsIds()
|
|
{
|
|
var path = PathOf("wholeview.cc");
|
|
using var cache = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem));
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
|
UpsertPlain(cache, "plain");
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "the store still holds a deck, whatever this commit wrote");
|
|
}
|
|
|
|
[Test]
|
|
public void AMergeOntoAFileAnotherWriterMarkedKeepsTheMarkerEvenWhenThisWriterHoldsNoIds()
|
|
{
|
|
var path = PathOf("merge.cc");
|
|
Type[] types = { typeof(IdDeck), typeof(PreCut2FixtureItem) };
|
|
using var first = OpenWith(path, false, types);
|
|
UpsertPlain(first, "first");
|
|
using var second = OpenWith(path, false, types);
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"));
|
|
first.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"));
|
|
|
|
Assert.That(second.Commit(batch =>
|
|
{
|
|
batch.Expect(new CultRecordKey("second"), null);
|
|
batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "second" }, new CultRecordKey("second"));
|
|
}), Is.True);
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "the file holds a deck another writer put there; second holds none");
|
|
}
|
|
|
|
[Test]
|
|
public void AnUnconditionalWriteOfAPlainRecordDoesNotPersistOrMarkTheIdsALoadedRecordMinted()
|
|
{
|
|
var path = PathOf("loaded-minted.cc");
|
|
WritePreIdStore(path, null, ("a", "a", 1));
|
|
var before = DiskRecord(path, "a");
|
|
using (var cache = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem)))
|
|
{
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"));
|
|
UpsertPlain(cache, "p");
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "nothing the write stored holds an id");
|
|
Assert.That(DiskRecord(path, "a").Payload, Is.EqualTo(before.Payload), "a is copied as it was stored");
|
|
cache.MintElementIds();
|
|
}
|
|
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "MintElementIds persisted a's minted ids");
|
|
Assert.That(AllIds(MessagePackSerializer.Deserialize<IdDeck>(DiskRecord(path, "a").Payload)), Has.All.Not.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public void AMarkedFileStaysMarkedWhenACommitOnlyTouchesOtherRecords()
|
|
{
|
|
var path = PathOf("sticky.cc");
|
|
using (var cache = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem)))
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
|
using (var cache = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem)))
|
|
{
|
|
Assert.That(cache.Commit(batch =>
|
|
{
|
|
batch.Expect(new CultRecordKey("plain"), null);
|
|
batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "plain" }, new CultRecordKey("plain"));
|
|
}), Is.True);
|
|
}
|
|
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "a merge onto a marked file does not shed the marker");
|
|
}
|
|
|
|
[Test]
|
|
public void ADirectoryStoreThatCanHoldElementIdsCarriesAV5ManifestAndStaysMarked()
|
|
{
|
|
var ids = PathOf("ids-dir.cc");
|
|
using (var cache = OpenWith(ids, true, typeof(IdDeck), typeof(PreCut2FixtureItem)))
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
|
Assert.That(HeaderOf(ids), Is.EqualTo("cultcache.store.v5.directory-content-addressed-pages"));
|
|
using (var cache = OpenWith(ids, true, typeof(IdDeck), typeof(PreCut2FixtureItem)))
|
|
{
|
|
Assert.That(cache.Get<IdDeck>(new CultRecordKey("d")), Is.Not.Null, "a v5 manifest reads");
|
|
UpsertPlain(cache, "plain");
|
|
}
|
|
|
|
Assert.That(HeaderOf(ids), Is.EqualTo("cultcache.store.v5.directory-content-addressed-pages"), "a rewrite that touches a plain record keeps the marker");
|
|
|
|
var mixed = PathOf("mixed-dir.cc");
|
|
using (var cache = OpenWith(mixed, true, typeof(IdDeck), typeof(PreCut2FixtureItem)))
|
|
{
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "p" }, new CultRecordKey("p"));
|
|
batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"));
|
|
});
|
|
}
|
|
|
|
Assert.That(HeaderOf(mixed), Is.EqualTo("cultcache.store.v5.directory-content-addressed-pages"), "one written record that can hold ids is enough");
|
|
|
|
var plain = PathOf("plain-dir.cc");
|
|
using (var cache = OpenWith(plain, true, typeof(PreCut2FixtureItem)))
|
|
UpsertPlain(cache, "a");
|
|
Assert.That(HeaderOf(plain), Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages"));
|
|
}
|
|
|
|
[Test]
|
|
public void AVariantStoreThatCanHoldElementIdsIsV3AndItsVariantsRead()
|
|
{
|
|
var path = PathOf("variant-v3.cc");
|
|
using (var cache = OpenWith(path, false, typeof(IdDeck)))
|
|
{
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), Deck("base"), new CultRecordKey("base"));
|
|
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), new[] { cache.Override<IdDeck>(nameof(IdDeck.Name), "v") });
|
|
});
|
|
}
|
|
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"));
|
|
using var again = OpenWith(path, false, typeof(IdDeck));
|
|
Assert.That(again.Get<IdDeck>(new CultRecordKey("v"))!.Name, Is.EqualTo("v"));
|
|
}
|
|
|
|
[Test]
|
|
public void ADuplicateIdOnDiskRefusesTheLoadNamingTheRecordTheListAndTheId()
|
|
{
|
|
var path = PathOf("dup-disk.cc");
|
|
WritePreIdStore(path, null, ("a", "a", 1));
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
var deck = Deck("dup");
|
|
deck.Reels[0].Id = HexA;
|
|
deck.Reels[1].Id = HexA;
|
|
deck.Reels[0].Marks[0].Id = HexA;
|
|
snapshot.Records[0].Payload = MessagePackSerializer.Serialize(deck);
|
|
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
|
|
|
var refusal = Refusal(Assert.Catch(() => Open(path).Dispose())!);
|
|
Assert.That(refusal.RecordKey, Is.EqualTo("a"));
|
|
Assert.That(refusal.ElementId, Is.EqualTo(HexA));
|
|
Assert.That(refusal.ListPath, Is.EqualTo("a.1"));
|
|
}
|
|
|
|
[Test]
|
|
public void AnIdOfTheWrongFormatOnDiskRefusesTheLoadAndAWriteOfItIsRefused()
|
|
{
|
|
var path = PathOf("bad-format.cc");
|
|
WritePreIdStore(path, null, ("a", "a", 1));
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
var deck = Deck("bad");
|
|
deck.Reels[0].Id = "t00000000001";
|
|
snapshot.Records[0].Payload = MessagePackSerializer.Serialize(deck);
|
|
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
|
var refusal = Refusal(Assert.Catch(() => Open(path).Dispose())!);
|
|
Assert.That(refusal.RecordKey, Is.EqualTo("a"));
|
|
Assert.That(refusal.ElementId, Is.EqualTo("t00000000001"));
|
|
|
|
using var cache = Open(PathOf("bad-write.cc"));
|
|
var written = Deck("w");
|
|
written.Reels[0].Marks[1].Id = "NOTHEX";
|
|
Assert.That(Assert.Throws<CultElementIdException>(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), written, new CultRecordKey("w"))))!.ElementId, Is.EqualTo("NOTHEX"));
|
|
}
|
|
|
|
[Test]
|
|
public void ABatchRefusedForADuplicateLeavesEveryDocumentItHeldUnminted()
|
|
{
|
|
using var cache = Open(PathOf("batch-refused.cc"));
|
|
var fine = Deck("fine");
|
|
var dup = Deck("dup");
|
|
dup.Reels[0].Id = HexA;
|
|
dup.Reels[1].Id = HexA;
|
|
Assert.Throws<CultElementIdException>(() => cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), fine, new CultRecordKey("fine"));
|
|
batch.Upsert(typeof(IdDeck), dup, new CultRecordKey("dup"));
|
|
}));
|
|
Assert.That(AllIds(fine), Has.All.Empty, "the refused batch minted nothing into the other document");
|
|
Assert.That(dup.Reels[0].Marks.Select(mark => mark.Id), Has.All.Empty);
|
|
Assert.That(cache.Get<IdDeck>(new CultRecordKey("fine")), Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public void AWriteThatDoesNotCommitGivesItsIdsBack()
|
|
{
|
|
using var cache = Open(PathOf("mismatch.cc"));
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("existing"), new CultRecordKey("existing")));
|
|
var fresh = Deck("fresh");
|
|
var outcome = cache.TryCommit(batch =>
|
|
{
|
|
batch.Expect(new CultRecordKey("existing"), null);
|
|
batch.Upsert(typeof(IdDeck), fresh, new CultRecordKey("fresh"));
|
|
});
|
|
Assert.That(outcome, Is.EqualTo(CultCommitOutcome.Mismatch));
|
|
Assert.That(AllIds(fresh), Has.All.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public void AnUnconditionalWriteLeavesTheLoadMintedIdsOfTheRecordsItDidNotStageOnTheList()
|
|
{
|
|
var path = PathOf("unconditional.cc");
|
|
WritePreIdStore(path, null, ("a", "a", 1), ("b", "b", 1));
|
|
using var cache = Open(path);
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("other"), new CultRecordKey("other")));
|
|
Assert.That(AllIds(MessagePackSerializer.Deserialize<IdDeck>(DiskRecord(path, "b").Payload)), Has.All.Empty, "the write did not touch b");
|
|
Assert.That(cache.MintElementIds(), Is.EqualTo(2), "a and b exist with ids only in memory until MintElementIds writes them");
|
|
Assert.That(AllIds(MessagePackSerializer.Deserialize<IdDeck>(DiskRecord(path, "b").Payload)), Has.All.Not.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public void AConditionalWriteThatLeavesOtherRecordsAsTheyWereLeavesThemOnTheList()
|
|
{
|
|
var path = PathOf("conditional.cc");
|
|
WritePreIdStore(path, null, ("a", "a", 1), ("b", "b", 1));
|
|
using var cache = Open(path);
|
|
Assert.That(cache.Commit(batch =>
|
|
{
|
|
batch.Expect(new CultRecordKey("fresh"), null);
|
|
batch.Upsert(typeof(IdDeck), Deck("fresh"), new CultRecordKey("fresh"));
|
|
}), Is.True);
|
|
Assert.That(cache.MintElementIds(), Is.EqualTo(2), "a and b were not written; their ids are still only in memory");
|
|
}
|
|
|
|
[Test]
|
|
public void AnOverrideValueGetsItsIdsMintedOnWriteWhoeverBuiltIt()
|
|
{
|
|
var path = PathOf("override-write.cc");
|
|
using (var cache = Open(path))
|
|
{
|
|
var reels = new List<IdReel> { new() { Label = "v", Marks = { new IdMark { Text = "x" } } }, new() { Label = "v" } };
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), Deck("base"), new CultRecordKey("base"));
|
|
// One built by the cache's own helper, one from bytes the id rule never saw.
|
|
batch.UpsertVariant(new CultRecordKey("helper"), new CultRecordKey("base"),
|
|
new[] { cache.Override<IdDeck>(nameof(IdDeck.Name), "helper"), cache.Override<IdDeck>(nameof(IdDeck.Reels), reels) });
|
|
batch.UpsertVariant(new CultRecordKey("raw"), new CultRecordKey("base"),
|
|
new[] { cache.Override<IdDeck>(nameof(IdDeck.Name), "raw"), CultVariantOverride.Set(1, OldShapedReels("r")) });
|
|
});
|
|
Assert.That(reels.Select(reel => reel.Id), Has.All.Empty, "the caller's list is not written to; ids are minted in the admitted copy");
|
|
foreach (var key in new[] { "helper", "raw" })
|
|
{
|
|
var resolved = cache.Get<IdDeck>(new CultRecordKey(key))!;
|
|
var ids = resolved.Reels.Select(reel => reel.Id).Concat(resolved.Reels.SelectMany(reel => reel.Marks.Select(mark => mark.Id))).ToArray();
|
|
Assert.That(ids, Has.All.Matches<string>(id => Regex.IsMatch(id, "^[0-9a-f]{12}$")), key);
|
|
}
|
|
}
|
|
|
|
foreach (var key in new[] { "helper", "raw" })
|
|
{
|
|
var stored = MessagePackSerializer.Deserialize<List<IdReel>>(DiskRecord(path, key).Variant!.Overrides.Single(entry => entry.Path[0].Slot == 1).Value);
|
|
Assert.That(stored.Select(reel => reel.Id), Has.All.Matches<string>(id => id.Length == 12), $"{key}: the persisted delta carries the ids");
|
|
using var reader = Open(path);
|
|
Assert.That(reader.Get<IdDeck>(new CultRecordKey(key))!.Reels.Select(reel => reel.Id), Is.EqualTo(stored.Select(reel => reel.Id)));
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void AnOverrideValueWithADuplicateIdIsRefusedNamingTheVariant()
|
|
{
|
|
using var cache = Open(PathOf("override-dup.cc"));
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("base"), new CultRecordKey("base")));
|
|
var dup = new List<IdReel> { new() { Id = HexA }, new() { Id = HexA } };
|
|
var refusal = Assert.Throws<CultElementIdException>(() => cache.Commit(batch =>
|
|
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"),
|
|
new[] { cache.Override<IdDeck>(nameof(IdDeck.Name), "v"), cache.Override<IdDeck>(nameof(IdDeck.Reels), dup) })))!;
|
|
Assert.That(refusal.RecordKey, Does.StartWith("v"));
|
|
Assert.That(refusal.ElementId, Is.EqualTo(HexA));
|
|
Assert.That(cache.Get<IdDeck>(new CultRecordKey("v")), Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public void AVariantWrittenBeforeIdsMintsItsOverrideIdsOnLoadAndMintElementIdsRewritesIt()
|
|
{
|
|
var path = PathOf("override-load.cc");
|
|
using (var seed = Open(path))
|
|
{
|
|
seed.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), Deck("base"), new CultRecordKey("base"));
|
|
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"),
|
|
new[] { seed.Override<IdDeck>(nameof(IdDeck.Name), "v"), seed.Override<IdDeck>(nameof(IdDeck.Reels), new List<IdReel> { new() }) });
|
|
});
|
|
}
|
|
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
var record = snapshot.Records.Single(entry => entry.Key == "v");
|
|
record.Variant = new CultVariantDelta(record.Variant!.BaseKey, record.Variant.Overrides
|
|
.Select(entry => entry.Path[0].Slot == 1 ? CultVariantOverride.Set(1, OldShapedReels("old")) : entry).ToArray());
|
|
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
|
var untouched = File.ReadAllBytes(path);
|
|
|
|
string[] first;
|
|
using (var peek = Open(path))
|
|
first = AllIds(peek.Get<IdDeck>(new CultRecordKey("v"))!);
|
|
using (var cache = Open(path))
|
|
{
|
|
Assert.That(AllIds(cache.Get<IdDeck>(new CultRecordKey("v"))!), Is.EqualTo(first), "the ids minted at load are a function of the record, so a second load mints the same");
|
|
Assert.That(first, Has.Length.EqualTo(3));
|
|
Assert.That(first, Has.All.Not.Empty);
|
|
Assert.That(File.ReadAllBytes(path), Is.EqualTo(untouched), "loading mints in memory and writes nothing");
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("other"), new CultRecordKey("other")));
|
|
// A base edit re-resolves the variant as a dependent; it is still only in memory.
|
|
var baseDeck = cache.Get<IdDeck>(new CultRecordKey("base"))!;
|
|
baseDeck.Reels[0].Label = "edited";
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), baseDeck, new CultRecordKey("base")));
|
|
Assert.That(AllIds(cache.Get<IdDeck>(new CultRecordKey("v"))!), Is.EqualTo(first), "the dependent re-resolves to the same minted ids");
|
|
Assert.That(cache.MintElementIds(), Is.EqualTo(1), "a flush of the store persisted the delta as it was handed over, so the variant is still in memory only");
|
|
Assert.That(cache.MintElementIds(), Is.EqualTo(0));
|
|
}
|
|
|
|
var persisted = MessagePackSerializer.Deserialize<List<IdReel>>(DiskRecord(path, "v").Variant!.Overrides.Single(entry => entry.Path[0].Slot == 1).Value);
|
|
Assert.That(persisted.Select(reel => reel.Id).Concat(persisted.SelectMany(reel => reel.Marks.Select(mark => mark.Id))), Is.EqualTo(first));
|
|
using var again = Open(path);
|
|
Assert.That(AllIds(again.Get<IdDeck>(new CultRecordKey("v"))!), Is.EqualTo(first));
|
|
}
|
|
|
|
[Test]
|
|
public void ARefusalAfterMintingLeavesEveryPlainDocumentInTheBatchUnminted()
|
|
{
|
|
using var cache = Open(PathOf("late-refusal.cc"));
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("base"), new CultRecordKey("base")));
|
|
var plain = Deck("plain");
|
|
var dup = new List<IdReel> { new() { Id = HexA }, new() { Id = HexA } };
|
|
Assert.Throws<CultElementIdException>(() => cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), plain, new CultRecordKey("plain"));
|
|
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"),
|
|
new[] { cache.Override<IdDeck>(nameof(IdDeck.Name), "v"), cache.Override<IdDeck>(nameof(IdDeck.Reels), dup) });
|
|
}));
|
|
Assert.That(AllIds(plain), Has.All.Empty, "the variant's refusal came after the plain record was minted; the mint was given back");
|
|
Assert.That(cache.Get<IdDeck>(new CultRecordKey("plain")), Is.Null);
|
|
}
|
|
|
|
// ---- C2a fix batch 3: mark by content, one object twice ----
|
|
|
|
private static IdDeck EmptyDeck(string name) => new() { Name = name };
|
|
|
|
[Test]
|
|
public void ASingleFileStoreIsMarkedByTheIdsItHoldsNotByTheTypesItCouldHold()
|
|
{
|
|
var empty = PathOf("empty-deck.cc");
|
|
using (var cache = Open(empty))
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")));
|
|
Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v1"), "a deck with no elements holds no id");
|
|
|
|
var flushed = PathOf("flushed.cc");
|
|
using (var cache = Open(flushed))
|
|
{
|
|
cache.UpsertAsync(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")).GetAwaiter().GetResult();
|
|
cache.FlushAllBackingStores();
|
|
}
|
|
|
|
Assert.That(HeaderOf(flushed), Is.EqualTo("cultcache.store.v1"), "the whole-store flush decides by content too");
|
|
|
|
using (var cache = Open(flushed))
|
|
{
|
|
cache.UpsertAsync(typeof(IdDeck), Deck("d"), new CultRecordKey("d")).GetAwaiter().GetResult();
|
|
cache.FlushAllBackingStores();
|
|
}
|
|
|
|
Assert.That(HeaderOf(flushed), Is.EqualTo("cultcache.store.v3"), "one element with an id marks the flush");
|
|
|
|
var marked = PathOf("marked.cc");
|
|
using (var cache = Open(marked))
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
|
using (var cache = Open(marked))
|
|
{
|
|
Assert.That(cache.Commit(batch =>
|
|
{
|
|
batch.Expect(new CultRecordKey("e"), null);
|
|
batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e"));
|
|
}), Is.True);
|
|
}
|
|
|
|
Assert.That(HeaderOf(marked), Is.EqualTo("cultcache.store.v3"), "a file already marked on disk stays marked");
|
|
}
|
|
|
|
[Test]
|
|
public void AnIdAlreadyThereMarksTheStoreAndSoDoesARecordLoadedHoldingOne()
|
|
{
|
|
var preset = PathOf("preset.cc");
|
|
using (var cache = Open(preset))
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "p", Reels = { new IdReel { Id = HexA } } }, new CultRecordKey("p")));
|
|
Assert.That(HeaderOf(preset), Is.EqualTo("cultcache.store.v3"), "the deck minted nothing, and still holds an id");
|
|
|
|
// The deck reloads holding ids it did not mint; a whole-store flush of a store now holding only that deck and an empty one.
|
|
using (var cache = Open(preset))
|
|
{
|
|
cache.UpsertAsync(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")).GetAwaiter().GetResult();
|
|
cache.FlushAllBackingStores();
|
|
}
|
|
|
|
Assert.That(HeaderOf(preset), Is.EqualTo("cultcache.store.v3"), "a loaded record's ids count");
|
|
}
|
|
|
|
// A record a caller builds and hands straight to a store never passes an admission; the flag still means what it persists.
|
|
[TestCase(false, "cultcache.store.v3")]
|
|
[TestCase(true, "cultcache.store.v5.directory-content-addressed-pages")]
|
|
public void ARecordPushedStraightToAStoreIsMarkedByTheIdsItHolds(bool directory, string marked)
|
|
{
|
|
var path = PathOf(directory ? "direct-dir.cc" : "direct.cc");
|
|
using var cache = OpenWith(path, directory, typeof(IdDeck));
|
|
var store = cache.BackingStores[0];
|
|
var descriptor = Registry.GetRequired<IdDeck>();
|
|
var withId = new IdDeck { Name = "d", Reels = { new IdReel { Id = HexA } } };
|
|
|
|
store.Push(new CultStoredDocument(new CultRecordKey("d"), "2026-09-29T00:00:00.0000000+00:00", descriptor, withId));
|
|
store.PushAll();
|
|
Assert.That(HeaderOf(path), Is.EqualTo(marked));
|
|
|
|
// The directory store writes only its dirty pages and keeps a marked manifest; the single file is whole-store.
|
|
if (directory) return;
|
|
store.Push(new CultStoredDocument(new CultRecordKey("d"), "2026-09-29T00:00:00.0000000+00:00", descriptor, EmptyDeck("d")));
|
|
store.PushAll();
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "a whole-store flush decides by what the store now holds");
|
|
}
|
|
|
|
// Soul's P7 and P10: a store reads the header off what it writes, when it writes it. A staged document changed after it was
|
|
// staged, loaded or not, is marked by what it holds at the flush, in both directions. A document changed in place and not
|
|
// staged is not written, so it marks nothing.
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public void AFlushIsMarkedByWhatAStagedDocumentHoldsNowNotWhatItHeldWhenStaged(bool loaded)
|
|
{
|
|
var path = PathOf(loaded ? "mutated-loaded.cc" : "mutated-staged.cc");
|
|
var key = new CultRecordKey("d");
|
|
using (var seed = Open(path))
|
|
seed.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("d"), key));
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"));
|
|
var seeded = DiskRecord(path, "d");
|
|
|
|
using var cache = Open(path);
|
|
var deck = loaded ? cache.Get<IdDeck>(key)! : EmptyDeck("d");
|
|
void Stage() => cache.UpsertAsync(typeof(IdDeck), deck, key).GetAwaiter().GetResult();
|
|
if (!loaded)
|
|
Stage();
|
|
|
|
deck.Reels.Add(new IdReel { Id = HexA });
|
|
if (loaded)
|
|
{
|
|
cache.BackingStores[0].PushAll();
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "a change in place that was never staged is not written");
|
|
Assert.That(DiskRecord(path, "d").Payload, Is.EqualTo(seeded.Payload));
|
|
Stage();
|
|
}
|
|
|
|
cache.BackingStores[0].PushAll();
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "an id added after staging marks the flush");
|
|
|
|
deck.Reels.Clear();
|
|
Stage();
|
|
cache.BackingStores[0].PushAll();
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "the last id taken away after staging unmarks it");
|
|
}
|
|
|
|
// A store with no cache has no codec to read a variant's override values with, so it cannot see whether they hold an id: a
|
|
// variant it writes counts as holding one. The same record read through a cache is marked by what its values hold. A
|
|
// flush that stages nothing copies the variant as stored.
|
|
[Test]
|
|
public void AVariantAStoreCannotReadCountsAsHoldingIdsWhenItWritesIt()
|
|
{
|
|
var path = PathOf("detached-variant.cc");
|
|
using (var cache = Open(path))
|
|
{
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base"));
|
|
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), new[] { cache.Override<IdDeck>(nameof(IdDeck.Name), "v") });
|
|
});
|
|
}
|
|
|
|
using (var cache = Open(path))
|
|
cache.BackingStores[0].PushAll();
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "read with a codec, a name override holds no id");
|
|
|
|
var blind = new SingleFileMessagePackBackingStore(path);
|
|
blind.UseRegistry(Registry);
|
|
blind.PullAll();
|
|
blind.PushAll();
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "a flush that staged nothing copies the variant and its header");
|
|
|
|
blind.Push(new CultStoredDocument(
|
|
new CultRecordKey("v"),
|
|
DateTimeOffset.UtcNow.ToString("O"),
|
|
Registry.GetRequired(typeof(IdDeck)),
|
|
new CultVariantDelta("base", Array.Empty<CultVariantOverride>()),
|
|
null,
|
|
null));
|
|
blind.PushAll();
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "read without a codec, the variant it writes cannot be seen, so it is marked");
|
|
}
|
|
|
|
// Through a real attach: the second cache reads the variant from the file, and the flush it then makes writes the header its
|
|
// resolved values hold, never the one a codecless copy would claim (a codecless variant counts as holding ids).
|
|
[TestCase("name", "cultcache.store.v2")]
|
|
[TestCase("reels", "cultcache.store.v3")]
|
|
public void AnAttachedVariantStoreFlushesTheHeaderItsValuesHold(string overridden, string header)
|
|
{
|
|
var path = PathOf("attached-variant-" + overridden + ".cc");
|
|
using (var cache = Open(path))
|
|
{
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base"));
|
|
var name = cache.Override<IdDeck>(nameof(IdDeck.Name), "v");
|
|
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), overridden == "name"
|
|
? new[] { name }
|
|
: new[] { name, cache.Override<IdDeck>(nameof(IdDeck.Reels), new List<IdReel> { new() { Label = "r", Id = HexA } }) });
|
|
});
|
|
}
|
|
|
|
using (var second = Open(path))
|
|
{
|
|
second.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("other"), new CultRecordKey("other")));
|
|
second.FlushAllBackingStores();
|
|
}
|
|
|
|
Assert.That(HeaderOf(path), Is.EqualTo(header));
|
|
}
|
|
|
|
[Test]
|
|
public void ADirectoryStoreIsMarkedByTheIdsItWritesAndAnEmptyDeckDoesNotMarkIt()
|
|
{
|
|
var empty = PathOf("empty-dir.cc");
|
|
using (var cache = OpenWith(empty, true, typeof(IdDeck)))
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")));
|
|
Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages"));
|
|
|
|
using (var cache = OpenWith(empty, true, typeof(IdDeck)))
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
|
Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v5.directory-content-addressed-pages"));
|
|
}
|
|
|
|
[Test]
|
|
public void AVariantIsMarkedByItsOverrideIdsAndOtherwiseIsV2()
|
|
{
|
|
var plain = PathOf("variant-plain.cc");
|
|
using (var cache = Open(plain))
|
|
{
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base"));
|
|
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), new[] { cache.Override<IdDeck>(nameof(IdDeck.Name), "v") });
|
|
});
|
|
}
|
|
|
|
Assert.That(HeaderOf(plain), Is.EqualTo("cultcache.store.v2"));
|
|
|
|
var withIds = PathOf("variant-ids.cc");
|
|
using (var cache = Open(withIds))
|
|
{
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base"));
|
|
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"),
|
|
new[] { cache.Override<IdDeck>(nameof(IdDeck.Name), "v"), cache.Override<IdDeck>(nameof(IdDeck.Reels), new List<IdReel> { new() { Label = "r" } }) });
|
|
});
|
|
}
|
|
|
|
Assert.That(HeaderOf(withIds), Is.EqualTo("cultcache.store.v3"));
|
|
|
|
// Reloaded, the variant is the record the store keeps: a whole-store flush beside an empty deck still marks the file.
|
|
using (var cache = Open(withIds))
|
|
{
|
|
cache.UpsertAsync(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")).GetAwaiter().GetResult();
|
|
cache.FlushAllBackingStores();
|
|
}
|
|
|
|
Assert.That(HeaderOf(withIds), Is.EqualTo("cultcache.store.v3"));
|
|
|
|
// Flattened, the variant is a plain record holding the ids its override brought; the base holds none.
|
|
using (var cache = Open(withIds))
|
|
{
|
|
cache.FlattenAsync(new CultRecordKey("v")).GetAwaiter().GetResult();
|
|
cache.Remove(new CultRecordKey("e"));
|
|
cache.FlushAllBackingStores();
|
|
}
|
|
|
|
Assert.That(HeaderOf(withIds), Is.EqualTo("cultcache.store.v3"));
|
|
}
|
|
|
|
// Soul's probe: the base gains ids in the batch that flattens the variant, then leaves. The flattened record is the
|
|
// document it stores, so its ids mark the file whatever the base did before or after.
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public void AFlattenedVariantIsMarkedByTheDocumentItStoresNotByTheOneItWasPlannedAgainst(bool viaFlush)
|
|
{
|
|
var path = PathOf(viaFlush ? "flatten-flush.cc" : "flatten-commit.cc");
|
|
using (var cache = Open(path))
|
|
{
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base"));
|
|
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), new[] { cache.Override<IdDeck>(nameof(IdDeck.Name), "v") });
|
|
});
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"));
|
|
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), Deck("base", reels: 1), new CultRecordKey("base"));
|
|
batch.Flatten(new CultRecordKey("v"));
|
|
});
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"));
|
|
|
|
if (viaFlush)
|
|
{
|
|
cache.Remove(new CultRecordKey("base"));
|
|
cache.FlushAllBackingStores();
|
|
}
|
|
else
|
|
{
|
|
Assert.That(cache.Commit(batch => batch.Remove(new CultRecordKey("base"))), Is.True);
|
|
}
|
|
}
|
|
|
|
var flattenedIds = AllIds(MessagePackSerializer.Deserialize<IdDeck>(DiskRecord(path, "v").Payload));
|
|
Assert.That(flattenedIds, Is.Not.Empty, "the flattened record holds the base's ids");
|
|
Assert.That(flattenedIds, Has.All.Not.Empty);
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "the file holds ids, so it is marked");
|
|
}
|
|
|
|
// A conditional commit lands onto the file as it is: it sees only its own batch, so ids only this cache minted (they are on no
|
|
// disk) do not mark the file.
|
|
[Test]
|
|
public void AConditionalCommitOntoLoadMintedIdsDoesNotMarkTheFile()
|
|
{
|
|
Type[] types = { typeof(IdDeck), typeof(PreCut2FixtureItem) };
|
|
var preId = PathOf("conditional-minted.cc");
|
|
WritePreIdStore(preId, null, ("a", "a", 1));
|
|
using (var cache = OpenWith(preId, false, types))
|
|
{
|
|
Assert.That(cache.Commit(batch =>
|
|
{
|
|
batch.Expect(new CultRecordKey("plain"), null);
|
|
batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "plain" }, new CultRecordKey("plain"));
|
|
}), Is.True);
|
|
}
|
|
|
|
Assert.That(HeaderOf(preId), Is.EqualTo("cultcache.store.v1"), "a's ids exist only in memory; the file holds none");
|
|
}
|
|
|
|
// The last id leaves the store in this commit: an unconditional commit writes the store it will hold, not the one it held.
|
|
[Test]
|
|
public void AnUnconditionalCommitThatRemovesOrReplacesTheLastIdWritesV1()
|
|
{
|
|
var removed = PathOf("last-removed.cc");
|
|
using (var cache = Open(removed))
|
|
{
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
|
Assert.That(HeaderOf(removed), Is.EqualTo("cultcache.store.v3"));
|
|
cache.Commit(batch => batch.Remove(new CultRecordKey("d")));
|
|
}
|
|
|
|
Assert.That(HeaderOf(removed), Is.EqualTo("cultcache.store.v1"));
|
|
|
|
var replaced = PathOf("last-replaced.cc");
|
|
using (var cache = Open(replaced))
|
|
{
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
|
cache.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("d"), new CultRecordKey("d")));
|
|
}
|
|
|
|
Assert.That(HeaderOf(replaced), Is.EqualTo("cultcache.store.v1"));
|
|
}
|
|
|
|
// Commits one plain item through a cache over `path` and reports how many copied records that write decoded to ask whether
|
|
// they hold an element id, and the header it left.
|
|
private (int Decoded, string Header) CommitItemCountingDecodes(string path, params Type[] types)
|
|
{
|
|
var registry = CultDocumentRegistry.ForTypes(types);
|
|
var store = new SingleFileMessagePackBackingStore(path);
|
|
using var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry));
|
|
cache.AddBackingStore(store);
|
|
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
|
|
store.CopiedRecordsDecoded = 0;
|
|
cache.Commit(batch => batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "p" }, new CultRecordKey("p")));
|
|
return (store.CopiedRecordsDecoded, HeaderOf(path));
|
|
}
|
|
|
|
// The copy check decodes a record only where the answer can change: each gate, removed, makes one of these counts wrong.
|
|
[Test]
|
|
public void TheCopyCheckDecodesOnlyWhatCouldMarkTheStore()
|
|
{
|
|
// A v3 header is already marked: nothing to decide.
|
|
var marked = PathOf("gate-marked.cc");
|
|
WritePreIdStore(marked, null, ("a", "a", 1), ("b", "b", 1), ("c", "c", 1));
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(marked));
|
|
snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV3;
|
|
File.WriteAllBytes(marked, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
|
Assert.That(CommitItemCountingDecodes(marked, typeof(IdDeck), typeof(PreCut2FixtureItem)), Is.EqualTo((0, "cultcache.store.v3")), "a marked store decodes nothing");
|
|
|
|
// Records whose type reaches no object list can hold no id.
|
|
var noLists = PathOf("gate-no-lists.cc");
|
|
using (var seed = OpenWith(noLists, false, typeof(PreCut2FixtureItem)))
|
|
{
|
|
foreach (var key in new[] { "x", "y", "z" })
|
|
UpsertPlain(seed, key);
|
|
}
|
|
|
|
Assert.That(CommitItemCountingDecodes(noLists, typeof(PreCut2FixtureItem)), Is.EqualTo((0, "cultcache.store.v1")), "a type with no object list is not decoded");
|
|
|
|
// A written record that holds an id already marks the store, whichever of the written records it is.
|
|
var stagedPath = PathOf("gate-staged.cc");
|
|
WritePreIdStore(stagedPath, null, ("a", "a", 1), ("b", "b", 1), ("c", "c", 1));
|
|
var stagedStore = new SingleFileMessagePackBackingStore(stagedPath);
|
|
var stagedRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck), typeof(PreCut2FixtureItem) });
|
|
using (var cache = new CultCache(stagedRegistry, CultCacheMessagePack.CreateCodec(stagedRegistry)))
|
|
{
|
|
cache.AddBackingStore(stagedStore);
|
|
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
|
|
stagedStore.CopiedRecordsDecoded = 0;
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "plain" }, new CultRecordKey("plain"));
|
|
batch.Upsert(typeof(IdDeck), Deck("fresh"), new CultRecordKey("fresh"));
|
|
});
|
|
}
|
|
|
|
Assert.That(stagedStore.CopiedRecordsDecoded, Is.EqualTo(0), "a staged record holding an id marks without asking");
|
|
Assert.That(HeaderOf(stagedPath), Is.EqualTo("cultcache.store.v3"));
|
|
|
|
// Id-capable records without ids: each is decoded once, and the store stays unmarked.
|
|
var capable = PathOf("gate-capable.cc");
|
|
WritePreIdStore(capable, null, ("a", "a", 1), ("b", "b", 1), ("c", "c", 1));
|
|
Assert.That(CommitItemCountingDecodes(capable, typeof(IdDeck), typeof(PreCut2FixtureItem)), Is.EqualTo((3, "cultcache.store.v1")));
|
|
|
|
// A record the writer's registry does not claim is foreign: no type, no decode.
|
|
var foreign = PathOf("gate-foreign.cc");
|
|
WritePreIdStore(foreign, null, ("a", "a", 1), ("b", "b", 1), ("c", "c", 1));
|
|
Assert.That(CommitItemCountingDecodes(foreign, typeof(PreCut2FixtureItem)), Is.EqualTo((0, "cultcache.store.v1")), "a foreign record is not decoded");
|
|
|
|
// A variant is copied as its bytes: only a C# write of one holding ids marks the store, and that write marked it already.
|
|
var variant = PathOf("gate-variant.cc");
|
|
WriteV2StoreWithAnIdLessVariant(variant);
|
|
Assert.That(CommitItemCountingDecodes(variant, typeof(IdDeck), typeof(PreCut2FixtureItem)), Is.EqualTo((1, "cultcache.store.v2")), "the plain base is decoded, the variant is not");
|
|
}
|
|
|
|
// Soul's probe: a C1 v2 store whose variant override holds no ids. Loading mints them in memory; the store keeps the delta
|
|
// it read, so the file holds none and no write of a plain record beside it may mark the file.
|
|
private static void WriteV2StoreWithAnIdLessVariant(string path)
|
|
{
|
|
using (var seed = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem)))
|
|
{
|
|
seed.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base"));
|
|
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"),
|
|
new[] { seed.Override<IdDeck>(nameof(IdDeck.Name), "v"), seed.Override<IdDeck>(nameof(IdDeck.Reels), new List<IdReel> { new() }) });
|
|
});
|
|
}
|
|
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
var record = snapshot.Records.Single(entry => entry.Key == "v");
|
|
record.Variant = new CultVariantDelta(record.Variant!.BaseKey, record.Variant.Overrides
|
|
.Select(entry => entry.Path[0].Slot == 1 ? CultVariantOverride.Set(1, OldShapedReels("old")) : entry).ToArray());
|
|
snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV2;
|
|
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
|
}
|
|
|
|
[TestCase("unconditional")]
|
|
[TestCase("conditional")]
|
|
[TestCase("flush")]
|
|
public void AVariantWhoseIdsExistOnlyInMemoryDoesNotMarkTheFileAPlainWriteLandsIn(string write)
|
|
{
|
|
var path = PathOf("variant-in-memory-" + write + ".cc");
|
|
WriteV2StoreWithAnIdLessVariant(path);
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"));
|
|
|
|
using (var cache = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem)))
|
|
{
|
|
Assert.That(AllIds(cache.Get<IdDeck>(new CultRecordKey("v"))!), Has.All.Not.Empty, "the variant holds minted ids in memory");
|
|
var key = new CultRecordKey("p");
|
|
switch (write)
|
|
{
|
|
case "unconditional":
|
|
UpsertPlain(cache, "p");
|
|
break;
|
|
case "conditional":
|
|
Assert.That(cache.Commit(batch =>
|
|
{
|
|
batch.Expect(key, null);
|
|
batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "p" }, key);
|
|
}), Is.True);
|
|
break;
|
|
default:
|
|
cache.UpsertAsync(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "p" }, key).GetAwaiter().GetResult();
|
|
cache.FlushAllBackingStores();
|
|
break;
|
|
}
|
|
}
|
|
|
|
Assert.That(DiskRecord(path, "p"), Is.Not.Null);
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "the file holds no id, whatever this cache minted in memory");
|
|
}
|
|
|
|
// Soul's A/B probe: a conditional commit lands onto the file as it is, so a stale view of what the file once held does not
|
|
// decide the header. A holds a deck with ids; B removes it; A commits a plain record conditionally.
|
|
[Test]
|
|
public void AConditionalCommitIsNotMarkedByIdsItsStaleViewHoldsAndTheFileNoLongerDoes()
|
|
{
|
|
var path = PathOf("stale-view.cc");
|
|
Type[] types = { typeof(IdDeck), typeof(PreCut2FixtureItem) };
|
|
using var a = OpenWith(path, false, types);
|
|
a.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
|
using (var b = OpenWith(path, false, types))
|
|
b.Commit(batch => batch.Remove(new CultRecordKey("d")));
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "B's whole-store commit left no id");
|
|
Assert.That(a.Get<IdDeck>(new CultRecordKey("d")), Is.Not.Null, "A has not pulled: its view is stale");
|
|
|
|
Assert.That(a.Commit(batch =>
|
|
{
|
|
batch.Expect(new CultRecordKey("plain"), null);
|
|
batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "plain" }, new CultRecordKey("plain"));
|
|
}), Is.True);
|
|
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "the merged file holds no id");
|
|
}
|
|
|
|
// Any override holding an id marks the variant, wherever it sits among the overrides.
|
|
[Test]
|
|
public void AVariantWhoseIdBearingOverrideIsNotTheLastIsStillMarked()
|
|
{
|
|
var path = PathOf("variant-order.cc");
|
|
using (var cache = Open(path))
|
|
{
|
|
cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base"));
|
|
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"),
|
|
new[] { cache.Override<IdDeck>(nameof(IdDeck.Reels), new List<IdReel> { new() { Label = "r" } }), cache.Override<IdDeck>(nameof(IdDeck.Name), "v") });
|
|
});
|
|
}
|
|
|
|
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"));
|
|
}
|
|
|
|
[Test]
|
|
public void AnElementObjectTwiceInOneListIsRefusedAtWriteAndNothingIsPersisted()
|
|
{
|
|
var path = PathOf("shared.cc");
|
|
using (var cache = Open(path))
|
|
{
|
|
var shared = new IdMark { Text = "s" };
|
|
var deck = new IdDeck { Name = "shared", Reels = { new IdReel { Label = "r", Marks = { shared, shared } } } };
|
|
var refusal = Assert.Throws<CultElementIdException>(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("shared"))))!;
|
|
Assert.That(refusal.Message, Does.Contain("shared").And.Contain("twice"));
|
|
Assert.That(refusal.RecordKey, Is.EqualTo("shared"));
|
|
Assert.That(shared.Id, Is.Empty, "the refusal minted nothing");
|
|
Assert.That(cache.Get<IdDeck>(new CultRecordKey("shared")), Is.Null);
|
|
}
|
|
|
|
Assert.That(File.Exists(path), Is.False, "nothing was persisted");
|
|
}
|
|
|
|
[Test]
|
|
public void ADerivedIdWhoseSourceIsEmptyIsRefusedAtWriteNamingTheSourceMember()
|
|
{
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(LabelDoc) });
|
|
using var cache = CultCacheMessagePack.Create(PathOf("label.cc"), new CultCacheOpenOptions { Registry = registry });
|
|
var doc = new LabelDoc { Name = "l", Labels = { new IdLabel { Label = "ok" }, new IdLabel { Label = null } } };
|
|
var refusal = Assert.Throws<CultElementIdException>(() => cache.Commit(batch => batch.Upsert(typeof(LabelDoc), doc, new CultRecordKey("l"))))!;
|
|
Assert.That(refusal.Member, Is.EqualTo("Label"));
|
|
Assert.That(refusal.RecordKey, Is.EqualTo("l"));
|
|
Assert.That(cache.Get<LabelDoc>(new CultRecordKey("l")), Is.Null);
|
|
}
|
|
|
|
[CultDocument("tests.element_id_label_doc", "tests.element_id_label_doc.v1")]
|
|
[MessagePackObject]
|
|
public sealed class LabelDoc
|
|
{
|
|
[Key(0)] [CultName] public string Name { get; set; } = "";
|
|
[Key(1)] public List<IdLabel> Labels { get; set; } = new();
|
|
}
|
|
|
|
[MessagePackObject]
|
|
public sealed class IdLabel
|
|
{
|
|
[Key(0)] public string? Label { get; set; }
|
|
[Key(1)] [CultElementId(nameof(Label))] public string Id { get; set; } = "";
|
|
}
|
|
|
|
[CultDocument("tests.element_id_span_doc", "tests.element_id_span_doc.v1")]
|
|
[MessagePackObject]
|
|
public sealed class SpanDoc
|
|
{
|
|
[Key(0)] [CultName] public string Name { get; set; } = "";
|
|
[Key(1)] public List<IdSpan> Spans { get; set; } = new();
|
|
}
|
|
|
|
[MessagePackObject]
|
|
public sealed class IdSpan
|
|
{
|
|
[Key(0)] public long Offset { get; set; }
|
|
[Key(1)] public int Size { get; set; }
|
|
[Key(2)] [CultElementId(nameof(Offset))] public string Id { get; set; } = "";
|
|
}
|
|
|
|
[MessagePackObject]
|
|
public sealed class IdMark
|
|
{
|
|
[Key(0)] public string Text { get; set; } = "";
|
|
[Key(1)] [CultElementId] public string Id { get; set; } = "";
|
|
}
|
|
|
|
[MessagePackObject]
|
|
public sealed class IdReel
|
|
{
|
|
[Key(0)] public string Label { get; set; } = "";
|
|
[Key(1)] public List<IdMark> Marks { get; set; } = new();
|
|
[Key(2)] [CultElementId] public string Id { get; set; } = "";
|
|
}
|
|
|
|
[MessagePackObject]
|
|
[Union(0, typeof(IdSlash))]
|
|
[Union(1, typeof(IdStab))]
|
|
public abstract class IdMove
|
|
{
|
|
[Key(0)] [CultElementId] public string Id { get; set; } = "";
|
|
}
|
|
|
|
[MessagePackObject]
|
|
public sealed class IdSlash : IdMove
|
|
{
|
|
[Key(1)] public int Reach { get; set; }
|
|
}
|
|
|
|
[MessagePackObject]
|
|
public sealed class IdStab : IdMove
|
|
{
|
|
[Key(1)] public int Depth { get; set; }
|
|
}
|
|
|
|
[CultDocument("tests.element_id_whole_holder", "tests.element_id_whole_holder.v1")]
|
|
[MessagePackObject]
|
|
public sealed class IdWholeHolder
|
|
{
|
|
[Key(0)] [CultName] public string Name { get; set; } = "";
|
|
[Key(1)] public ImmutableArray<IdReel> Reels { get; set; } = ImmutableArray<IdReel>.Empty;
|
|
}
|
|
|
|
[CultDocument("tests.element_id_deck", "tests.element_id_deck.v1")]
|
|
[MessagePackObject]
|
|
public sealed class IdDeck
|
|
{
|
|
[Key(0)] [CultName] public string Name { get; set; } = "";
|
|
[Key(1)] public List<IdReel> Reels { get; set; } = new();
|
|
[Key(2)] public List<IdMove> Moves { get; set; } = new();
|
|
}
|
|
|
|
// The shapes before ids: same slots, no id member. Not documents, so never registered.
|
|
[MessagePackObject]
|
|
public sealed class OldMark
|
|
{
|
|
[Key(0)] public string Text { get; set; } = "";
|
|
}
|
|
|
|
[MessagePackObject]
|
|
public sealed class OldReel
|
|
{
|
|
[Key(0)] public string Label { get; set; } = "";
|
|
[Key(1)] public List<OldMark> Marks { get; set; } = new();
|
|
}
|
|
|
|
[MessagePackObject]
|
|
public sealed class OldDeck
|
|
{
|
|
[Key(0)] public string Name { get; set; } = "";
|
|
[Key(1)] public List<OldReel> Reels { get; set; } = new();
|
|
}
|
|
}
|
|
}
|