Union golden: the store's clock is an internal seam, the writer pins it, the gate test runs under any switch

CultCache mints storedAt from an internal Clock (default the wall clock); the vector writer pins it, so
union-surface.cc is regenerated in process and byte-compared on every run. The gate test sets the switch
itself and writes only a probe, so it neither skips nor touches a committed vector.

This commit does not pass: union-surface.cc still holds the old wall-clock stored-at; the next commit
regenerates it. Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 02:58:54 +02:00
commit b4edbb1eeb
5 changed files with 116 additions and 18 deletions

View file

@ -2337,9 +2337,12 @@ namespace GameCult.Caching
throw new ArgumentException($"Expect({key.Value}) was given an instance this cache does not hold at that key.", nameof(current));
}
private static string MintStoredAt(string? previous)
// The wall clock behind every storedAt. Internal: a test that writes a golden store pins it, so the bytes reproduce.
internal Func<DateTimeOffset> Clock { get; set; } = () => DateTimeOffset.UtcNow;
private string MintStoredAt(string? previous)
{
var now = DateTimeOffset.UtcNow;
var now = Clock();
if (previous != null &&
DateTimeOffset.TryParse(previous, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var last) &&
now <= last)

View file

@ -8,5 +8,6 @@
<PackageReference Include="MessagePack.Annotations" Version="3.1.7" />
<PackageReference Include="R3" Version="1.3.0" />
<PackageReference Include="ConcurrentHashSet " Version="1.3.0" />
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,69 @@
#nullable enable
using System;
using System.Globalization;
using System.IO;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// The clock behind every storedAt: the wall clock by default, and a pinned one when a golden store has to reproduce.
public class CultCacheClockTests
{
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnionParityVectorTests.UnionSurface) });
private static readonly CultRecordKey Key = new("surface");
private string _directory = string.Empty;
[SetUp]
public void CreateDirectory()
{
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-clock-{Guid.NewGuid():N}");
Directory.CreateDirectory(_directory);
}
[TearDown]
public void DeleteDirectory()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
private CultCache Open() => CultCacheMessagePack.Create(Path.Combine(_directory, "clock.cc"), new CultCacheOpenOptions { Registry = Registry });
private static string Upsert(CultCache cache)
{
cache.UpsertAsync(typeof(UnionParityVectorTests.UnionSurface), new UnionParityVectorTests.UnionSurface(), Key).GetAwaiter().GetResult();
return cache.GetStored(Key)!.StoredAt;
}
[Test]
public void ADefaultCacheStampsTheWallClock()
{
using var cache = Open();
var before = DateTimeOffset.UtcNow;
var stamp = DateTimeOffset.Parse(Upsert(cache), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
var after = DateTimeOffset.UtcNow;
Assert.That(stamp, Is.InRange(before, after));
}
[Test]
public void APinnedClockIsTheStoredAt()
{
var pinned = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
using var cache = Open();
cache.Clock = () => pinned;
Assert.That(Upsert(cache), Is.EqualTo("2026-01-01T00:00:00.0000000+00:00"));
}
[Test]
public void APinnedClockStillIncreasesStoredAtPerKey()
{
var pinned = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
using var cache = Open();
cache.Clock = () => pinned;
Upsert(cache);
Assert.That(Upsert(cache), Is.EqualTo("2026-01-01T00:00:00.0000001+00:00"), "the same instant again is one tick later");
}
}
}

View file

@ -20,6 +20,9 @@ namespace GameCult.Caching.Tests
private const string WriteVariable = "CULTCACHE_WRITE_VECTORS";
private const string Canary = "CANARY-union-golden-4c2a";
// The stored-at of the one record in union-surface.cc: the store's clock is pinned to it, so the store reproduces.
private static readonly DateTimeOffset StoredAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnionSurface) });
private static bool Writing => Environment.GetEnvironmentVariable(WriteVariable) == "1";
@ -329,17 +332,15 @@ namespace GameCult.Caching.Tests
try
{
var scratch = Path.Combine(work, "union-surface.cc");
if (Writing)
using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry, StoreFlushOnDispose = true }))
{
using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry, StoreFlushOnDispose = true }))
{
cache.UpsertAsync(typeof(UnionSurface), value, new CultRecordKey("surface")).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
}
Check("union-surface.cc", File.ReadAllBytes(scratch));
cache.Clock = () => StoredAt;
cache.UpsertAsync(typeof(UnionSurface), value, new CultRecordKey("surface")).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
}
Check("union-surface.cc", File.ReadAllBytes(scratch));
// The committed store: one record whose payload is the committed payload, holding the constructed value, and rewritten unchanged.
var store = Committed("union-surface.cc");
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(store);
@ -428,19 +429,42 @@ namespace GameCult.Caching.Tests
}
}
[Test]
// Runs whatever the switch reads when the test starts: it sets the switch itself, and writes only a probe file.
[Test, NonParallelizable]
public void VectorsNotRewrittenByDefault()
{
if (Writing) Assert.Ignore($"{WriteVariable}=1 is the one way to write the vectors.");
string Fingerprint() => string.Join("|", Directory.EnumerateFiles(VectorDirectory()).OrderBy(f => f, StringComparer.Ordinal)
.Select(f => Path.GetFileName(f) + "=" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(f)))));
const string probe = "gate-probe.msgpack";
var path = VectorPath(probe);
var before = Fingerprint();
Assert.Throws<AssertionException>(() => Check("union-surface.msgpack", new byte[] { 0x90 }), "bytes that differ from the vector fail the run");
Assert.Throws<AssertionException>(() => Check("not-a-vector.msgpack", new byte[] { 0x90 }), "a missing vector fails the run");
Assert.That(Fingerprint(), Is.EqualTo(before), "an ordinary run writes nothing");
Assert.That(File.Exists(VectorPath("not-a-vector.msgpack")), Is.False);
var inherited = Environment.GetEnvironmentVariable(WriteVariable);
try
{
foreach (var off in new string?[] { null, string.Empty, "0", "true", "11" })
{
Environment.SetEnvironmentVariable(WriteVariable, off);
Assert.Throws<AssertionException>(() => Check(probe, new byte[] { 0x90 }), $"a missing vector fails the run, switch {off ?? "unset"}");
Assert.That(File.Exists(path), Is.False, $"switch {off ?? "unset"} writes nothing");
File.WriteAllBytes(path, new byte[] { 0x91 });
Assert.Throws<AssertionException>(() => Check(probe, new byte[] { 0x90 }), $"bytes that differ from the vector fail the run, switch {off ?? "unset"}");
Assert.That(File.ReadAllBytes(path), Is.EqualTo(new byte[] { 0x91 }), $"switch {off ?? "unset"} rewrites nothing");
File.Delete(path);
}
Environment.SetEnvironmentVariable(WriteVariable, "1");
Check(probe, new byte[] { 0x90 });
Assert.That(File.ReadAllBytes(path), Is.EqualTo(new byte[] { 0x90 }), "1 is the one way to write a vector");
}
finally
{
Environment.SetEnvironmentVariable(WriteVariable, inherited);
if (File.Exists(path)) File.Delete(path);
}
Assert.That(Fingerprint(), Is.EqualTo(before), "no committed vector changed");
}
}
}

View file

@ -24,7 +24,8 @@ Composition is a record, not an arm: `[Name, Body: Node]`.
CraftedItem field) is Part `gear`; Missing is nil. A union inside a record inside a union, a unit arm, a list of
unions with a nil element, an inner union keyed apart from its outer. Written by C#.
- `union-surface.cc`: a single-file store written by the C# store holding that one record under key `surface`. Its
payload equals `union-surface.msgpack`; rewriting the loaded store is byte-identical.
payload equals `union-surface.msgpack`; rewriting the loaded store is byte-identical. The writer pins the store's
clock, so the record's stored-at is `2026-01-01T00:00:00.0000000+00:00` and the whole file reproduces byte for byte.
- `outcomes.msgpack`: the expected outcome of every file below, as data. An array of entries
`[file, outcome, union, key, detail]`: `outcome` is `refused` or `read-only`; `union` is the union (or record)
named; `key` is the arm key or nil; `detail` is as in the tables below.