CultLib/tests/GameCult.Caching.Tests/SlotReadOnlyTests.cs

639 lines
36 KiB
C#

#nullable enable
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GameCult.Caching.MessagePack;
using MessagePack;
using MessagePack.Formatters;
using NUnit.Framework;
using Surface = GameCult.Caching.Tests.UnionParityVectorTests.UnionSurface;
namespace GameCult.Caching.Tests
{
// cultlib-gaps:ruling:extra-trailing-slots and :slot-mark-consent. A record whose payload carries a slot past its reader's declared
// slots, at any depth, is held read-only: every staged write or removal of it is refused before anything is staged. A compatible-id
// declaration consents only to depth-0 slots the catalog comparison reports ignored under a listed compatible id.
public class SlotReadOnlyTests
{
private const string Extra = "extra-slot";
// Slot 1 and 2 are retired gaps.
[CultDocument("tests.slot_gap", "tests.slot_gap.v1")]
[MessagePackObject]
public sealed class GapDoc
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(3)] public string Tail = string.Empty;
}
private static readonly CultDocumentRegistry SurfaceRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(Surface) });
private static readonly CultDocumentRegistry GapRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(GapDoc) });
private static readonly CultRecordKey Marked = new("marked");
private static readonly CultRecordKey Clean = new("clean");
private static readonly CultRecordKey Unheld = new("unheld");
private string _directory = string.Empty;
[SetUp]
public void CreateDirectory()
{
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-slotro-{Guid.NewGuid():N}");
Directory.CreateDirectory(_directory);
}
[TearDown]
public void DeleteDirectory()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
private static CultCache Open(string path, CultDocumentRegistry registry) =>
CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry });
private static byte[] Emit(WriterAction action)
{
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
action(ref writer);
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
private delegate void WriterAction(ref MessagePackWriter writer);
// A store of two records of the given type, the marked one carrying the payload and the clean one an ordinary value.
private string Seed(CultDocumentRegistry registry, Type type, Func<object> clean, byte[] markedPayload, string name = "slots.cc")
{
var path = Path.Combine(_directory, name);
using (var cache = Open(path, registry))
{
cache.UpsertAsync(type, clean(), Marked).GetAwaiter().GetResult();
cache.UpsertAsync(type, clean(), Clean).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
}
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
snapshot.Records.Single(record => record.Key == Marked.Value).Payload = markedPayload;
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
return path;
}
private string SeedSurface(string vector) =>
Seed(SurfaceRegistry, typeof(Surface), () => new Surface { Id = "x" }, VectorFiles.Committed("union-parity", vector));
private static CultStoredDocument Stored(CultCache cache, CultRecordKey key) => cache.GetStored(key)!;
// ---- a mark at every depth, with the type and both counts ----
[TestCase("extra-slot-record.msgpack", "UnionSurface", 8, 7, 1)]
[TestCase("extra-slot-nested-record.msgpack", nameof(UnionParityVectorTests.Composition), 3, 2, 1)]
[TestCase("extra-slot-arm.msgpack", nameof(UnionParityVectorTests.Box), 3, 2, 1)]
[TestCase("extra-slot-list-element.msgpack", nameof(UnionParityVectorTests.Text), 3, 2, 1)]
[TestCase("depth-skipped-at-limit.msgpack", "UnionSurface", 8, 7, 1)]
public void AMarkAtAnyDepthHoldsTheRecordReadOnly(string vector, string typeName, int slots, int declared, int occurrences)
{
var path = SeedSurface(vector);
using var cache = Open(path, SurfaceRegistry);
var unread = Stored(cache, Marked).UnreadSlots;
Assert.That(unread, Is.Not.Null, vector);
Assert.That(unread!.TypeName, Is.EqualTo(typeName), vector);
Assert.That(unread.SlotCount, Is.EqualTo(slots), vector);
Assert.That(unread.DeclaredCount, Is.EqualTo(declared), vector);
Assert.That(unread.Occurrences, Is.EqualTo(occurrences), vector);
Assert.That(unread.Explained, Is.False, "the reader's own id is not a compatible-id declaration: nothing explains the mark");
Assert.That(cache.IsWritable(Marked), Is.False, vector);
Assert.That(cache.Get<Surface>(Marked), Is.Not.Null, "a read-only record is still served");
Assert.That(Stored(cache, Clean).UnreadSlots, Is.Null);
Assert.That(cache.IsWritable(Clean), Is.True);
Assert.That(cache.IsWritable(Unheld), Is.True, "a key no store holds is writable");
}
// ---- every staged write and removal of a marked key is refused, nothing else is ----
private static void AssertSlotRefusal(TestDelegate act, string what, string type, int slots, int declared)
{
var refusal = Assert.Throws<CultSchemaConflictException>(act, what)!;
Assert.That(refusal.UnreadSlots, Is.Not.Null, what);
Assert.That(refusal.RecordKey, Is.EqualTo("marked"), what);
Assert.That(refusal.Message, Does.Contain("is read-only: " + type + " was read with " + slots + " slots where this build declares " + declared)
.And.Contain("record marked"), what);
Assert.That(refusal.Message, Does.Not.Contain(Extra), $"{what}: the refusal never echoes a slot value");
}
[Test]
public void EveryWriteAndRemovalOfAMarkedRecordIsRefusedAndAnotherKeyStillWrites()
{
var path = SeedSurface("extra-slot-nested-record.msgpack");
var before = File.ReadAllBytes(path);
using (var cache = Open(path, SurfaceRegistry))
{
var composition = nameof(UnionParityVectorTests.Composition);
AssertSlotRefusal(() => cache.Commit(batch => batch.Upsert(new Surface { Id = "new" }, new CultRecordHandle<Surface>(Marked))), "upsert", composition, 3, 2);
AssertSlotRefusal(() => cache.Commit(batch => batch.UpsertVariant(Marked, Clean)), "variant", composition, 3, 2);
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "remove", composition, 3, 2);
AssertSlotRefusal(() => cache.Commit(batch => { batch.Upsert(new Surface { Id = "other" }, new CultRecordHandle<Surface>(Clean)); batch.Remove(Marked); }), "mixed batch", composition, 3, 2);
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "refused writes leave the store as it was");
Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "changed" }, new CultRecordHandle<Surface>(Clean))), Is.True);
}
Assert.That(CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Count, Is.EqualTo(2));
}
// ---- consent ----
[Test]
public void ADeclaredIdDoesNotConsentToAnUnexplainedMark()
{
// The reader's own id is declared; the mark at depth 0 is in no compatible id's comparison: still read-only.
var path = SeedSurface("extra-slot-record.msgpack");
using var cache = Open(path, SurfaceRegistry);
AssertSlotRefusal(() => cache.Commit(batch => batch.Upsert(new Surface { Id = "new" }, new CultRecordHandle<Surface>(Marked))), "depth 0 under the reader's own id", "UnionSurface", 8, 7);
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "removal", "UnionSurface", 8, 7);
}
// Every row of slot-outcomes.msgpack, from the committed files alone: the mark, Explained and IsWritable of each record of
// extra-slot-store.cc and extra-slot-consent.cc, and a removal of the key refused exactly where IsWritable is false.
[Test]
public void EverySlotOutcomeRowHoldsAndARemovalIsRefusedExactlyWhereTheRecordIsNotWritable()
{
var reader = new MessagePackReader(VectorFiles.Committed("union-parity", "slot-outcomes.msgpack"));
var rows = reader.ReadArrayHeader();
Assert.That(rows, Is.EqualTo(8));
for (var i = 0; i < rows; i++)
{
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(9));
var store = reader.ReadString()!;
var key = new CultRecordKey(reader.ReadString()!);
var writable = reader.ReadBoolean();
bool? explained = reader.TryReadNil() ? null : reader.ReadBoolean();
var type = reader.TryReadNil() ? null : reader.ReadString();
var counts = new int?[3];
for (var c = 0; c < 3; c++) counts[c] = reader.TryReadNil() ? null : reader.ReadInt32();
var (slots, declared, occurrences) = (counts[0], counts[1], counts[2]);
var flattenRefusedBy = reader.TryReadNil() ? null : reader.ReadString();
var row = $"{store} {key.Value}";
var path = Path.Combine(_directory, $"{i}-{store}");
File.WriteAllBytes(path, VectorFiles.Committed("union-parity", store));
var before = File.ReadAllBytes(path);
using var cache = Open(path, UnionParityVectorTests.SlotReader(store));
var unread = Stored(cache, key).UnreadSlots;
if (explained == null)
{
Assert.That(unread, Is.Null, row);
}
else
{
Assert.That(unread, Is.Not.Null, row);
Assert.That((unread!.Explained, unread.TypeName, unread.SlotCount, unread.DeclaredCount, unread.Occurrences),
Is.EqualTo((explained.Value, type, slots, declared, occurrences)), row);
}
Assert.That(cache.IsWritable(key), Is.EqualTo(writable), row);
Assert.That(cache.IsWritable(Unheld), Is.True, "a key no store holds is writable");
if (flattenRefusedBy != null)
{
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch => batch.Flatten(key)), $"{row}: flatten")!;
Assert.That(refusal.RecordKey, Is.EqualTo(flattenRefusedBy), $"{row}: the refusal is the base's own");
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), $"{row}: a refused flatten leaves the store as it was");
}
if (writable)
{
Assert.That(cache.Commit(batch => batch.Remove(key)), Is.True, row);
}
else
{
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch => batch.Remove(key)), row)!;
Assert.That(refusal.UnreadSlots, Is.Not.Null, row);
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), $"{row}: a refused removal leaves the store as it was");
}
}
}
// Consent reaches only the slots the catalog comparison reports: the wide id's catalog reports slot 7, so a payload carrying
// slots 7 and 8 at depth 0 is not consented to, and a payload carrying slot 7 alone is.
[Test]
public void ConsentStopsAtTheSlotsTheCatalogReports()
{
var path = Path.Combine(_directory, "extra-slot-consent.cc");
File.WriteAllBytes(path, VectorFiles.Committed("union-parity", "extra-slot-consent.cc"));
var before = File.ReadAllBytes(path);
using var cache = Open(path, UnionParityVectorTests.SlotReader("extra-slot-consent.cc"));
var over = new CultRecordHandle<UnionParityVectorTests.SurfaceDeclaring>(new CultRecordKey("over-consent"));
AssertWriteAgrees(cache, over.Key, () => cache.Commit(batch => batch.Upsert(new UnionParityVectorTests.SurfaceDeclaring { Id = "over" }, over)));
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the refused write leaves the store as it was");
var consented = new CultRecordHandle<UnionParityVectorTests.SurfaceDeclaring>(new CultRecordKey("consented"));
AssertWriteAgrees(cache, consented.Key, () => cache.Commit(batch => batch.Upsert(new UnionParityVectorTests.SurfaceDeclaring { Id = "consented" }, consented)));
Assert.That(cache.IsWritable(consented.Key), Is.True);
}
// ---- carried verbatim ----
[Test]
public void AMarkedRecordIsCarriedByteForByteWhenAnotherRecordIsWritten()
{
var path = Path.Combine(_directory, "extra-slot-store.cc");
File.WriteAllBytes(path, VectorFiles.Committed("union-parity", "extra-slot-store.cc"));
var before = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
var original = before.Records.Single(record => record.Key == Marked.Value);
using (var cache = Open(path, SurfaceRegistry))
{
Assert.That(cache.IsWritable(Marked), Is.False);
Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "changed" }, new CultRecordHandle<Surface>(Clean))), Is.True);
}
var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
var carried = after.Records.Single(record => record.Key == Marked.Value);
Assert.That(carried.Payload, Is.EqualTo(original.Payload));
Assert.That(carried.SchemaId, Is.EqualTo(original.SchemaId));
Assert.That(carried.StoredAt, Is.EqualTo(original.StoredAt));
Assert.That(after.SchemaCatalog.Select(CultDocumentMessagePackSerialization.SerializeCatalogEntry).Select(Convert.ToHexString),
Is.EqualTo(before.SchemaCatalog.Select(CultDocumentMessagePackSerialization.SerializeCatalogEntry).Select(Convert.ToHexString)));
Assert.That(after.Records.Single(record => record.Key == Clean.Value).Payload, Is.Not.EqualTo(before.Records.Single(record => record.Key == Clean.Value).Payload));
}
// ---- slots short of the reader's, and gaps below the highest key, do not mark ----
[Test]
public void MissingTrailingSlotsDefaultAndAGapSlotDoesNotMark()
{
var short1 = Emit((ref MessagePackWriter w) => { w.WriteArrayHeader(1); w.Write("short"); });
var path = Seed(GapRegistry, typeof(GapDoc), () => new GapDoc { Name = "g" }, short1);
using (var cache = Open(path, GapRegistry))
{
Assert.That(cache.Get<GapDoc>(Marked)!.Tail, Is.EqualTo(string.Empty), "a missing trailing slot keeps its default");
Assert.That(Stored(cache, Marked).UnreadSlots, Is.Null);
Assert.That(cache.IsWritable(Marked), Is.True);
Assert.That(cache.Commit(batch => batch.Upsert(new GapDoc { Name = "short", Tail = "t" }, new CultRecordHandle<GapDoc>(Marked))), Is.True);
}
var gapped = Emit((ref MessagePackWriter w) => { w.WriteArrayHeader(4); w.Write("gap"); w.WriteNil(); w.Write("in a retired slot"); w.Write("tail"); });
var gapPath = Seed(GapRegistry, typeof(GapDoc), () => new GapDoc { Name = "g" }, gapped, "gap.cc");
using (var cache = Open(gapPath, GapRegistry))
{
Assert.That(Stored(cache, Marked).UnreadSlots, Is.Null, "a non-nil value in a retired gap below the highest key does not mark");
Assert.That(cache.IsWritable(Marked), Is.True);
}
}
// ---- the catalog's consent still works, and is reported as explained ----
[CultDocument("tests.slot_wide", "tests.slot_wide.v1")]
[MessagePackObject]
public sealed class WideDoc
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public string Colour = string.Empty;
[Key(2)] public string Extra = string.Empty;
}
[CultDocument("tests.slot_wide", "tests.slot_wide.v2")]
[MessagePackObject]
public sealed class NarrowDoc
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public string Colour = string.Empty;
}
[Test]
public void ADepthZeroSlotTheCatalogReportsIsExplainedAndTheUndeclaredRecordStaysUnwritable([Values] bool directory)
{
var path = Path.Combine(_directory, directory ? "store" : "store.cc");
CacheBackingStore Store() => directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path);
using (var writer = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(WideDoc) })))
{
writer.AddBackingStore(Store());
Assert.That(writer.Commit(batch => batch.Upsert(new WideDoc { Name = "w", Extra = "kept?" }, new CultRecordHandle<WideDoc>(Marked))), Is.True);
}
using var reader = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(NarrowDoc) }));
reader.AddBackingStore(Store());
var unread = Stored(reader, Marked).UnreadSlots;
Assert.That(unread, Is.Not.Null, "the store kind decodes through the marking path");
Assert.That(unread!.Explained, Is.True, "depth 0 and reported by the catalog comparison");
Assert.That(unread.TypeName, Is.EqualTo(nameof(NarrowDoc)));
Assert.That(unread.SlotCount, Is.EqualTo(3));
Assert.That(unread.DeclaredCount, Is.EqualTo(2));
Assert.That(reader.IsWritable(Marked), Is.False, "the wide id is undeclared: unowned, though the mark is explained");
Assert.Throws<CultSchemaConflictException>(() => reader.Commit(batch => batch.Remove(Marked)));
}
// ---- declared slots are the slots the type's own formatter writes ----
// MessagePack writes a non-public [Key] member of an AllowPrivate type: 2 slots, where a public-member count says 1.
[MessagePackObject(AllowPrivate = true)]
public sealed class PrivInner
{
[Key(0)] public string A = string.Empty;
[Key(1)] internal string B = string.Empty;
}
// A [Key] getter that throws on an uninitialized instance; the parameterless constructor's instance is written instead.
[MessagePackObject]
public sealed class Counted
{
private List<string> _items = new();
[Key(0)] public int Count { get => _items.Count; set => _items = Enumerable.Repeat(string.Empty, value).ToList(); }
}
// The same getter and no parameterless constructor: no instance can be written, so the guard leaves the type alone.
[MessagePackObject]
public sealed class Uncounted
{
private readonly List<string> _items;
[SerializationConstructor] public Uncounted(int count) => _items = Enumerable.Repeat(string.Empty, count).ToList();
[Key(0)] public int Count => _items.Count;
}
// A type naming its own formatter owns its layout: one [Key] slot, but the formatter writes the item count and each item.
[MessagePackObject]
[MessagePackFormatter(typeof(BagFormatter))]
public sealed class Bag
{
[Key(0)] public List<string> Items = new();
}
public sealed class BagFormatter : IMessagePackFormatter<Bag?>
{
public void Serialize(ref MessagePackWriter writer, Bag? value, MessagePackSerializerOptions options)
{
if (value == null) { writer.WriteNil(); return; }
writer.WriteArrayHeader(1 + value.Items.Count);
writer.Write(value.Items.Count);
foreach (var item in value.Items) writer.Write(item);
}
public Bag? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
if (reader.TryReadNil()) return null;
var count = reader.ReadArrayHeader();
reader.ReadInt32();
var bag = new Bag();
for (var i = 1; i < count; i++) bag.Items.Add(reader.ReadString()!);
return bag;
}
}
// A [MessagePackObject] type an assembly resolver formats as [count, items...], with no [MessagePackFormatter] attribute: the
// resolver owns its layout.
[MessagePackObject]
public sealed class ResolvedBag
{
[Key(0)] public List<string> Items = new();
}
public sealed class ResolvedBagResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new ResolvedBagResolver();
private static readonly IMessagePackFormatter<ResolvedBag?> Formatter = new ResolvedBagFormatter();
public IMessagePackFormatter<T>? GetFormatter<T>() => typeof(T) == typeof(ResolvedBag) ? (IMessagePackFormatter<T>)Formatter : null;
private sealed class ResolvedBagFormatter : IMessagePackFormatter<ResolvedBag?>
{
private static readonly BagFormatter Bag = new();
public void Serialize(ref MessagePackWriter writer, ResolvedBag? value, MessagePackSerializerOptions options) =>
Bag.Serialize(ref writer, value == null ? null : new Bag { Items = value.Items }, options);
public ResolvedBag? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) =>
Bag.Deserialize(ref reader, options) is { } bag ? new ResolvedBag { Items = bag.Items } : null;
}
}
// A type an assembly resolver claims by handing out MessagePack's own [Key]-layout formatter: still measured.
[MessagePackObject]
public sealed class Relayed
{
[Key(0)] public string A = string.Empty;
}
public sealed class RelayResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new RelayResolver();
public IMessagePackFormatter<T>? GetFormatter<T>() =>
typeof(T) == typeof(Relayed) ? global::MessagePack.Resolvers.StandardResolver.Instance.GetFormatter<T>() : null;
}
// A [MessagePackObject] collection: MessagePack writes it as its items, not by [Key].
[MessagePackObject]
public class TagList : List<string> { }
[CultDocument("tests.slot_measured", "tests.slot_measured.v1")]
[MessagePackObject]
public sealed class MeasuredDoc
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public PrivInner? Private;
[Key(2)] public Counted? Counted;
[Key(3)] public Uncounted? Uncounted;
[Key(4)] public Bag? Bag;
[Key(5)] public ResolvedBag? Resolved;
[Key(6)] public TagList? Tags;
[Key(7)] public Relayed? Relayed;
}
private static readonly CultDocumentRegistry MeasuredRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(MeasuredDoc) });
private static MeasuredDoc Measured(string member) => member switch
{
"private" => new MeasuredDoc { Name = member, Private = new PrivInner { A = "a", B = "bee" } },
"constructed" => new MeasuredDoc { Name = member, Counted = new Counted { Count = 2 } },
"unmeasurable" => new MeasuredDoc { Name = member, Uncounted = new Uncounted(2) },
"own-formatter" => new MeasuredDoc { Name = member, Bag = new Bag { Items = { "x", "y", "z" } } },
"resolver-formatted" => new MeasuredDoc { Name = member, Resolved = new ResolvedBag { Items = { "x", "y", "z" } } },
"collection" => new MeasuredDoc { Name = member, Tags = new TagList { "a", "b", "c" } },
_ => throw new ArgumentOutOfRangeException(nameof(member), member, null)
};
[TestCase("private", TestName = "APrivateKeyedMemberDoesNotMark")]
[TestCase("constructed", TestName = "ATypeMeasuredThroughItsConstructorDoesNotMark")]
[TestCase("unmeasurable", TestName = "AnUnmeasurableTypeIsNotGuarded")]
[TestCase("own-formatter", TestName = "ATypeWithItsOwnFormatterIsNotMeasuredByKeys")]
[TestCase("resolver-formatted", TestName = "ATypeAResolverFormatsIsNotMeasuredByKeys")]
[TestCase("collection", TestName = "AMessagePackObjectCollectionIsNotMeasuredByKeys")]
public void ARecordWrittenByItsOwnTypeDoesNotMark(string member)
{
var path = Path.Combine(_directory, "measured.cc");
using (var cache = Open(path, MeasuredRegistry))
{
Assert.That(cache.Commit(batch => batch.Upsert(Measured(member), new CultRecordHandle<MeasuredDoc>(Marked))), Is.True);
}
using (var cache = Open(path, MeasuredRegistry))
{
Assert.That(Stored(cache, Marked).UnreadSlots, Is.Null, member);
Assert.That(cache.IsWritable(Marked), Is.True, member);
Assert.That(cache.Commit(batch => batch.Upsert(Measured(member), new CultRecordHandle<MeasuredDoc>(Marked))), Is.True, member);
Assert.That(cache.Commit(batch => batch.Remove(Marked)), Is.True, member);
}
}
// One slot past what the formatter writes still marks, with the written count as declared.
[Test]
public void AnExtraSlotPastTheWrittenCountStillMarks()
{
var privatePayload = Emit((ref MessagePackWriter w) =>
{
w.WriteArrayHeader(5); w.Write("p");
w.WriteArrayHeader(3); w.Write("a"); w.Write("bee"); w.Write(Extra);
w.WriteNil(); w.WriteNil(); w.WriteNil();
});
var path = Seed(MeasuredRegistry, typeof(MeasuredDoc), () => new MeasuredDoc { Name = "c" }, privatePayload, "private.cc");
using (var cache = Open(path, MeasuredRegistry))
{
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "AllowPrivate type", nameof(PrivInner), 3, 2);
Assert.That(cache.IsWritable(Clean), Is.True);
}
var countedPayload = Emit((ref MessagePackWriter w) =>
{
w.WriteArrayHeader(5); w.Write("c"); w.WriteNil();
w.WriteArrayHeader(2); w.Write(0); w.Write(Extra);
w.WriteNil(); w.WriteNil();
});
path = Seed(MeasuredRegistry, typeof(MeasuredDoc), () => new MeasuredDoc { Name = "c" }, countedPayload, "counted.cc");
using (var cache = Open(path, MeasuredRegistry))
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "measured through its constructor", nameof(Counted), 2, 1);
// A resolver that hands out MessagePack's own [Key] formatter exempts nothing.
var relayedPayload = Emit((ref MessagePackWriter w) =>
{
w.WriteArrayHeader(8); w.Write("r");
for (var i = 0; i < 6; i++) w.WriteNil();
w.WriteArrayHeader(2); w.Write("a"); w.Write(Extra);
});
path = Seed(MeasuredRegistry, typeof(MeasuredDoc), () => new MeasuredDoc { Name = "c" }, relayedPayload, "relayed.cc");
using (var cache = Open(path, MeasuredRegistry))
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "relayed by a consumer resolver", nameof(Relayed), 2, 1);
}
// ---- a flatten writes its base chain's decoded view: refused when a record on the chain is not owned ----
private static readonly CultRecordKey Base = new("base");
private static readonly CultRecordKey Var = new("var");
private static readonly CultRecordKey Fresh = new("fresh");
// A store holding plain record base and its variant var, base then given the payload.
private string SeedVariant(byte[]? basePayload, string name)
{
var path = Path.Combine(_directory, name);
using (var cache = Open(path, SurfaceRegistry))
{
Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "base" }, new CultRecordHandle<Surface>(Base))), Is.True);
Assert.That(cache.Commit(batch => batch.UpsertVariant(Var, Base, Named(cache, Var))), Is.True);
}
if (basePayload == null) return path;
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
snapshot.Records.Single(record => record.Key == Base.Value).Payload = basePayload;
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
return path;
}
// A variant overrides its name.
private static CultVariantOverride[] Named(CultCache cache, CultRecordKey key) => new[] { cache.Override<Surface>(nameof(Surface.Id), key.Value) };
private static void AssertBaseRefusal(TestDelegate act, string what, bool unreadSlots)
{
var refusal = Assert.Throws<CultSchemaConflictException>(act, what)!;
Assert.That(refusal.RecordKey, Is.EqualTo(Base.Value), $"{what}: the refusal is the base's own");
Assert.That(refusal.UnreadSlots, unreadSlots ? Is.Not.Null : Is.Null, what);
}
[Test]
public void AFlattenThroughABaseHeldForUnreadSlotsIsRefusedAndTheVariantStillWritesItsDelta()
{
var nested = VectorFiles.Committed("union-parity", "extra-slot-nested-record.msgpack");
var path = SeedVariant(nested, "flatten.cc");
var before = File.ReadAllBytes(path);
using (var cache = Open(path, SurfaceRegistry))
{
Assert.That(cache.IsWritable(Base), Is.False);
Assert.That(cache.IsWritable(Var), Is.True, "the variant persists only its delta");
AssertBaseRefusal(() => cache.Commit(batch => batch.Flatten(Var)), "Commit(Flatten)", unreadSlots: true);
AssertBaseRefusal(() => cache.FlattenAsync(Var).GetAwaiter().GetResult(), "FlattenAsync", unreadSlots: true);
AssertBaseRefusal(() => cache.Commit(batch => { batch.UpsertVariant(Fresh, Base, Named(cache, Fresh)); batch.Flatten(Fresh); }), "a new variant flattened as it lands", unreadSlots: true);
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "refused flattens leave the store as it was");
Assert.That(cache.Commit(batch => batch.UpsertVariant(Var, Base, Named(cache, Var))), Is.True, "a variant write persists only its delta");
Assert.That(cache.Commit(batch => batch.Remove(Var)), Is.True);
}
var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
Assert.That(after.Records.Single(record => record.Key == Base.Value).Payload, Is.EqualTo(nested), "the base's bytes stay");
}
[Test]
public void AFlattenThroughABaseUnderAnUndeclaredDroppingIdIsRefused()
{
var path = Path.Combine(_directory, "drift.cc");
using (var writer = Open(path, CultDocumentRegistry.ForTypes(new[] { typeof(WideDoc) })))
Assert.That(writer.Commit(batch => batch.Upsert(new WideDoc { Name = "base", Extra = "kept?" }, new CultRecordHandle<WideDoc>(Base))), Is.True);
using var reader = Open(path, CultDocumentRegistry.ForTypes(new[] { typeof(NarrowDoc) }));
Assert.That(reader.IsWritable(Base), Is.False);
Assert.That(reader.Commit(batch => batch.UpsertVariant(Var, Base, new[] { reader.Override<NarrowDoc>(nameof(NarrowDoc.Name), Var.Value) })), Is.True, "a variant over the unowned base persists only its delta");
var before = File.ReadAllBytes(path);
AssertBaseRefusal(() => reader.Commit(batch => batch.Flatten(Var)), "flatten over undeclared drift", unreadSlots: false);
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
Assert.That(reader.Commit(batch => batch.Remove(Var)), Is.True);
}
// ---- IsWritable is the write's own answer ----
// False exactly where the write throws.
private static void AssertWriteAgrees(CultCache cache, CultRecordKey key, Action write)
{
var writable = cache.IsWritable(key);
Exception? thrown = null;
try { write(); }
catch (InvalidOperationException exception) { thrown = exception; }
Assert.That(thrown == null, Is.EqualTo(writable), $"IsWritable({key.Value}) is {writable}; the write {(thrown == null ? "landed" : "threw: " + thrown.Message)}");
}
[Test]
public void IsWritableIsFalseInAReadOnlyStoreAndAFlattenThereIsNotRefusedForItsCleanBase()
{
var path = SeedVariant(null, "readonly.cc");
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = SurfaceRegistry, ReadOnly = true });
Assert.That(cache.IsWritable(Base), Is.False);
AssertWriteAgrees(cache, Base, () => cache.Commit(batch => batch.Upsert(new Surface { Id = "new" }, new CultRecordHandle<Surface>(Base))));
AssertWriteAgrees(cache, Var, () => cache.Commit(batch => batch.Remove(Var)));
var refusal = Assert.Throws<InvalidOperationException>(() => cache.Commit(batch => batch.Flatten(Var)))!;
Assert.That(refusal, Is.Not.InstanceOf<CultSchemaConflictException>(), "nothing on the chain is lost: the refusal is the store's read-only one");
Assert.That(refusal.Message, Does.Contain("read-only backing store"));
}
// A key the cache does not hold, but a store carries as a foreign record: the store's removal check refuses it.
[Test]
public void IsWritableIsFalseForAnUnheldKeyAStoreListsForeign()
{
var path = Path.Combine(_directory, "foreign.cc");
using (var writer = Open(path, GapRegistry))
Assert.That(writer.Commit(batch => batch.Upsert(new GapDoc { Name = "g" }, new CultRecordHandle<GapDoc>(Marked))), Is.True);
using var cache = Open(path, SurfaceRegistry);
Assert.That(cache.GetStored(Marked), Is.Null, "the cache does not hold a record no registered type reads");
Assert.That(cache.IsWritable(Marked), Is.False);
AssertWriteAgrees(cache, Marked, () => cache.Commit(batch => batch.Remove(Marked)));
Assert.That(cache.IsWritable(Unheld), Is.True);
}
[Test]
public void IsWritableIsFalseForARecordNoAttachedStoreIsHomeTo()
{
using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(Surface), typeof(GapDoc) }));
Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "memory" }, new CultRecordHandle<Surface>(Clean))), Is.True);
Assert.That(cache.IsWritable(Clean), Is.True, "a store-less cache writes its own records");
cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(_directory, "gaps.cc")), typeof(GapDoc));
Assert.That(cache.IsWritable(Clean), Is.False);
AssertWriteAgrees(cache, Clean, () => cache.Commit(batch => batch.Upsert(new Surface { Id = "x" }, new CultRecordHandle<Surface>(Clean))));
AssertWriteAgrees(cache, Clean, () => cache.Commit(batch => batch.Remove(Clean)));
Assert.That(cache.IsWritable(Unheld), Is.True, "an unheld key no store lists foreign is writable");
}
}
}