CultLib/tests/GameCult.Caching.Tests/RefListDriftTests.cs
Meta Vens fd9bbaf4a4 Tests: the ref-list gate compares schema ids after one write at both trees; the C2a writer takes a fixed clock
The 1.0.60 ref-list summary is taken after attach, an upsert of alpha, beta and the list reversed, and a flush, at 2.0.1 and at
head, and names records by schema id (the restamp-on-write ruling makes them equal). C2aGateWriter stamps every record with one
instant and compares the committed vectors byte for byte, the directory store as relative path and bytes with lock files left out.
The committed vectors are regenerated in the next commit, so TheCommittedVectorsAreWhatC2aWrites is red at this commit; it builds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 07:57:29 +01:00

218 lines
11 KiB
C#

#nullable enable
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// 1.0.x persisted a list of record references (CultRecordRef<T>[]) as a plain value slot: isReference false, no target.
// 2.0 classifies the same slot as a reference to T. The payload is the same array of key strings, so a store written by
// 1.0.x opens: gaining reference classification on an otherwise identical slot is compatible drift. A reference that is
// lost, retargeted, or that arrives with a different type or cardinality stays a refusal.
public class RefListDriftTests
{
[CultDocument("tests.reflist_target", "tests.reflist_target.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class RefListTarget
{
[Key(0)]
[CultName]
public string Name = string.Empty;
}
[CultDocument("tests.reflist_holder", "tests.reflist_holder.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class RefListHolder
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public CultRecordRef<RefListTarget>[] Items = Array.Empty<CultRecordRef<RefListTarget>>();
}
// The reverse of the 1.0.x drift: a slot persisted as a reference list, declared by a later version as a plain value.
[CultDocument("tests.reflist_loss", "tests.reflist_loss.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class RefListLossBefore
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public CultRecordRef<RefListTarget>[] Items = Array.Empty<CultRecordRef<RefListTarget>>();
}
[CultDocument("tests.reflist_loss", "tests.reflist_loss.v2")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class RefListLossAfter
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public string[] Items = Array.Empty<string>();
}
private static string VectorPath(string name)
{
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
{
var vectors = Path.Combine(dir.FullName, "tests", "vectors", "ref-list-drift");
if (Directory.Exists(vectors)) return Path.Combine(vectors, name);
}
throw new DirectoryNotFoundException($"tests/vectors/ref-list-drift not found above {TestContext.CurrentContext.TestDirectory}.");
}
[Test]
public async Task A_1_0_Store_With_A_Ref_List_Slot_Opens_And_Rewrites_Under_The_Reference_Schema()
{
var copy = Path.Combine(Path.GetTempPath(), $"reflist-drift-{Guid.NewGuid():N}.cc");
File.Copy(VectorPath("old-store.cc"), copy);
try
{
using var cache = new CultCache();
var store = new SingleFileMessagePackBackingStore(copy);
cache.AddBackingStore(store);
await cache.PullAllBackingStoresAsync();
Assert.That(
store.LastSchemaMigrationReports.Single(report => report.PersistedSchemaName == "tests.reflist_holder").Kind,
Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift));
var holder = cache.GetAll<RefListHolder>().Single();
Assert.That(holder.Items.Select(item => item.Key.Value), Is.EqualTo(new[] { "alpha", "beta" }));
Assert.That(cache.Get<RefListTarget>(holder.Items[0].Key)!.Name, Is.EqualTo("alpha"));
// A write flushes the store under the 2.0 schema; the list reads back unchanged.
await cache.AddAsync(new RefListTarget { Name = "gamma" });
store.PushAll();
using var reopened = new CultCache();
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy));
await reopened.PullAllBackingStoresAsync();
Assert.That(string.Join(",", reopened.GetAll<RefListTarget>().Select(target => target.Name).OrderBy(name => name)), Is.EqualTo("alpha,beta,gamma"));
Assert.That(reopened.GetAll<RefListHolder>().Single().Items.Select(item => item.Key.Value).ToArray(), Is.EqualTo(new[] { "alpha", "beta" }));
}
finally
{
File.Delete(copy);
}
}
// The store the 1.0.60 writer wrote (tests/vectors/ref-list-drift/v1-0-60-written.cc, made by OldWriterRefListProbe), or the one
// old-reader-refusal.sh gate csharp names in CULTLIB_GATE_OLD_STORE. It attaches the store, upserts alpha, beta and the list
// reversed, flushes, rereads the file and prints the REFLIST-SUMMARY line OldReaderRefListProbe prints at 2.0.1 after the same
// write; the gate diffs them, so the two must stay the same code. Ids are compared, not names: the write is what makes a record's
// schema id the one its type owns, and a record left under its persisted id is a type that cannot write it.
[Test]
public async Task A_1_0_60_Written_Store_Opens_Like_2_0_1()
{
var source = Environment.GetEnvironmentVariable("CULTLIB_GATE_OLD_STORE");
var copy = Path.Combine(Path.GetTempPath(), $"reflist-1060-{Guid.NewGuid():N}.cc");
File.Copy(string.IsNullOrEmpty(source) ? VectorPath("v1-0-60-written.cc") : source, copy);
try
{
string[] warnings;
using (var cache = new CultCache())
{
var store = new SingleFileMessagePackBackingStore(copy);
cache.AddBackingStore(store);
await cache.PullAllBackingStoresAsync();
warnings = store.LastSchemaMigrationReports.SelectMany(report => report.Warnings).Select(warning => warning.Code).Distinct().OrderBy(code => code, StringComparer.Ordinal).ToArray();
Assert.That(warnings, Does.Contain("gained_reference_semantics"));
var holder = cache.GetAll<RefListHolder>().Single();
Assert.That(holder.Items.Select(item => item.Key.Value), Is.EqualTo(new[] { "alpha", "beta" }));
// The write both runtimes make: the targets as they are, the holder with its list reversed.
foreach (var target in cache.GetAll<RefListTarget>().ToArray()) await cache.UpsertAsync(target, cache.TryGetHandle(target));
holder.Items = holder.Items.AsEnumerable().Reverse().ToArray();
await cache.UpsertAsync(holder, cache.TryGetHandle(holder));
await cache.FlushAsync();
}
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(copy));
var records = snapshot.Records.OrderBy(record => record.Key, StringComparer.Ordinal).Select(record => record.Key + "=" + record.SchemaId).ToArray();
var listId = snapshot.Records.Single(record => record.Key == "list").SchemaId;
var slot = snapshot.SchemaCatalog.Single(entry => entry.SchemaId == listId).Members.Single(member => member.MemberName == "Items");
using var reopened = new CultCache();
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy));
await reopened.PullAllBackingStoresAsync();
var items = reopened.GetAll<RefListHolder>().Single().Items.Select(item => item.Key.Value).ToArray();
Assert.That(items, Is.EqualTo(new[] { "beta", "alpha" }));
Assert.That(reopened.Get<RefListTarget>(reopened.GetAll<RefListHolder>().Single().Items[0].Key)!.Name, Is.EqualTo("beta"));
TestContext.Out.WriteLine("REFLIST-SUMMARY " + System.Text.Json.JsonSerializer.Serialize(new { records, items, itemsSlot = new { slot.IsReference, slot.TargetSchemaName }, warnings, header = snapshot.FormatVersion }));
}
finally
{
File.Delete(copy);
}
}
private static CultSchemaMigrationReport Resolve(Action<CultSchemaMemberCatalogEntry> persistedItems)
{
var catalog = CultDocumentRegistry.Shared.GetRequired<RefListHolder>().ToCatalogEntry();
catalog.SchemaId = "persisted.tests.reflist_holder.v1";
catalog.ContentHash = "sha256:persisted-by-1.0.x";
catalog.CompatibleSchemaIds = new[] { CultDocumentRegistry.Shared.GetRequired<RefListHolder>().SchemaId };
persistedItems(catalog.Members.Single(member => member.Slot == 1));
return CultDocumentRegistry.Shared.ResolvePersistedSchemaReport(catalog.SchemaId, new[] { catalog });
}
private static void AsWrittenBy10(CultSchemaMemberCatalogEntry member)
{
member.IsReference = false;
member.TargetSchemaName = null;
}
[Test]
public void A_Reference_Gained_On_An_Otherwise_Identical_Slot_Is_Compatible_Drift()
{
var report = Resolve(AsWrittenBy10);
Assert.That(report.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift));
Assert.That(report.Warnings.Select(warning => warning.Code), Does.Contain("gained_reference_semantics"));
}
[Test]
public void A_Persisted_Reference_Retargeted_Or_Gaining_Over_A_Foreign_Target_Still_Refuses()
{
Assert.That(
() => Resolve(member => member.TargetSchemaName = "tests.other_target"),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed target schema"));
Assert.That(
() => Resolve(member => { member.IsReference = false; member.TargetSchemaName = "tests.other_target"; }),
Throws.TypeOf<InvalidOperationException>());
}
[Test]
public void A_Lost_Reference_Still_Refuses()
{
var registry = CultDocumentRegistry.Shared;
var catalog = registry.GetRequired<RefListLossBefore>().ToCatalogEntry();
catalog.SchemaId = "persisted.tests.reflist_loss.v1";
catalog.ContentHash = "sha256:persisted-by-2.0";
catalog.CompatibleSchemaIds = new[] { registry.GetRequired<RefListLossAfter>().SchemaId };
catalog.Members.Single(member => member.Slot == 1).TypeName = "System.String[]";
Assert.That(
() => registry.ResolvePersistedSchemaReport(catalog.SchemaId, new[] { catalog }),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed reference semantics"));
}
[Test]
public void A_Gained_Reference_Does_Not_Excuse_A_Changed_Type_Or_Cardinality()
{
Assert.That(
() => Resolve(member => { AsWrittenBy10(member); member.TypeName = "System.String[]"; }),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed type"));
Assert.That(
() => Resolve(member => { AsWrittenBy10(member); member.IsMany = !member.IsMany; }),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed cardinality"));
}
}
}