Caching README Happy Path runs as written (same-key blind upsert, MessagePackObject), tested; UpdateTests defend the reload-failed conflict, variant apply count, A-B-A, removal, directory LostRace, held instance, gate, ledger
All checks were successful
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Successful in 27m14s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-13 15:12:16 +00:00
commit 240aef307b
4 changed files with 279 additions and 7 deletions

View file

@ -13,6 +13,7 @@ Cacheable models are plain classes annotated with cache intent.
using GameCult.Caching;
using MessagePack;
[MessagePackObject]
[CultDocument("gamecult.item_data", "gamecult.item_data.v1")]
public sealed class ItemData
{
@ -142,14 +143,14 @@ var result = cache.Update<ItemData>(key, item =>
return item;
});
// The blind opt-out: last writer wins per key.
// The blind opt-out: last writer wins per key. Name the key, or a new key claims the same unique Sku and is refused.
var handle = await cache.UpsertAsync(new ItemData
{
Name = "Potion",
Sku = "POT-1",
Category = "Consumable",
Value = 50
});
}, new CultRecordHandle<ItemData>(key));
await cache.FlushAsync();
```

View file

@ -70,15 +70,20 @@ namespace GameCult.Caching.Tests
// docs/start/README.md shows each tested snippet verbatim: the region between snippet:begin and snippet:end of its source file.
internal static class StartDocs
{
public static void AssertShowsSnippet(string sourcePath)
public static void AssertShowsSnippet(string sourcePath) =>
AssertShowsSnippet(sourcePath, Path.Combine("..", "..", "docs", "start", "README.md"));
// The same check against another document, its path relative to the directory of sourcePath. The snippet there is the
// statements of a block, so it need not open the fence (the block's usings come first).
public static void AssertShowsSnippet(string sourcePath, string docsRelativePath, bool wholeBlock = true)
{
var source = File.ReadAllText(sourcePath).Replace("\r\n", "\n");
const string begin = "// snippet:begin\n";
var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length;
var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)];
var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourcePath)!, "..", "..", "docs", "start", "README.md"));
Assert.That(docs.Replace("\r\n", "\n"), Does.Contain("```csharp\n" + snippet + "```"),
$"docs/start/README.md no longer shows {Path.GetFileName(sourcePath)} verbatim");
var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourcePath)!, docsRelativePath));
Assert.That(docs.Replace("\r\n", "\n"), Does.Contain(wholeBlock ? "```csharp\n" + snippet + "```" : snippet),
$"{docsRelativePath} no longer shows {Path.GetFileName(sourcePath)} verbatim");
}
}
}

View file

@ -0,0 +1,81 @@
#nullable enable
using System;
using System.IO;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// The Happy Path of src/GameCult.Caching/README.md, run as written: its statements are the region between snippet:begin and
// snippet:end below, and ReadmeShowsTheTestedSnippet fails when the README stops showing them verbatim.
public class ReadmeHappyPathTests
{
[CultDocument("gamecult.item_data", "gamecult.item_data.v1")]
[MessagePackObject]
public sealed class ItemData
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] [CultIndex] public string Sku = string.Empty;
[Key(2)] public string Category = string.Empty;
[Key(3)] public int Value;
}
private static async Task<CultCache> HappyPath()
{
// snippet:begin
var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack");
var key = new CultRecordKey("potion");
// The golden path: read, change a private copy, commit only if the record is unchanged, re-run on a lost race.
// Durable when it returns; no flush.
var result = cache.Update<ItemData>(key, item =>
{
item ??= new ItemData { Name = "Potion", Sku = "POT-1", Category = "Consumable" };
item.Value += 10;
return item;
});
// The blind opt-out: last writer wins per key. Name the key, or a new key claims the same unique Sku and is refused.
var handle = await cache.UpsertAsync(new ItemData
{
Name = "Potion",
Sku = "POT-1",
Category = "Consumable",
Value = 50
}, new CultRecordHandle<ItemData>(key));
await cache.FlushAsync();
// snippet:end
return cache;
}
[Test]
public async Task HappyPathRunsAsWritten()
{
var directory = Path.Combine(Path.GetTempPath(), $"readme-happy-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
var previous = Environment.CurrentDirectory;
try
{
Environment.CurrentDirectory = directory; // the README opens a relative path
using var cache = await HappyPath();
Assert.That(cache.Get<ItemData>(new CultRecordKey("potion"))!.Value, Is.EqualTo(50));
Assert.That(cache.GetByIndex<ItemData>("Sku", "POT-1"), Is.Not.Null);
}
finally
{
Environment.CurrentDirectory = previous;
Directory.Delete(directory, recursive: true);
}
}
[Test]
public void ReadmeShowsTheTestedSnippet() =>
StartDocs.AssertShowsSnippet(SourcePath(), Path.Combine("..", "..", "src", "GameCult.Caching", "README.md"), wholeBlock: false);
static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path;
}
}

View file

@ -239,8 +239,10 @@ namespace GameCult.Caching.Tests
}
before = File.ReadAllBytes(path);
var gearCalls = 0;
using (var cache = Open(path, false))
Assert.That(() => cache.Update<UpdateGear>(new CultRecordKey("gear-big"), gear => gear), Throws.InvalidOperationException);
Assert.That(() => cache.Update<UpdateGear>(new CultRecordKey("gear-big"), gear => { gearCalls++; return gear; }), Throws.InvalidOperationException);
Assert.That(gearCalls, Is.EqualTo(0), "apply ran for a variant");
Assert.That(calls, Is.EqualTo(0), "apply ran for a refused call");
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
@ -278,6 +280,189 @@ namespace GameCult.Caching.Tests
Assert.That(StoredValue(path, false), Is.EqualTo(2));
}
private static CultPersistedStoreSnapshot ReadSnapshot(string path) =>
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
// A reload that fails inside a conflict is a read failure, not a lost race: Update lets it propagate and never re-runs apply.
// Here the gear a variant is based on has gone foreign in the file, so the reload the conflict owes cannot drop it.
[Test]
public void ConflictWhoseReloadFailedPropagates()
{
var path = PathOf("reload-failed.cc");
Seed(path, false, 1);
using var cache = Open(path, false);
cache.Commit(batch => batch.Upsert(new UpdateGear { Name = "gear", Power = 1 }, new CultRecordHandle<UpdateGear>(new CultRecordKey("gear"))));
cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("gear-big"), new CultRecordKey("gear"),
new[] { cache.Override<UpdateGear>(nameof(UpdateGear.Name), "gear big"), cache.Override<UpdateGear>(nameof(UpdateGear.Power), 9) }));
const string foreignId = "sha256:00000000000000000000000000000000000000000000000000000000000050de";
var snapshot = ReadSnapshot(path);
snapshot.Records.Single(record => record.Key == "gear").SchemaId = foreignId;
snapshot.SchemaCatalog = snapshot.SchemaCatalog.Append(new CultSchemaCatalogEntry
{
SchemaId = foreignId, SchemaName = "tests.update_foreign", SchemaVersion = "tests.update_foreign.v1",
ContentHash = "foreign", CanonicalSchemaJson = "{}"
}).ToArray();
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var calls = 0;
var conflict = Assert.Throws<CultWriteConflictException>(() => cache.Update<UpdateCounter>(Key, counter => { calls++; return Plus(counter, 1); }))!;
Assert.That(conflict.InnerException, Is.Not.Null, "the reload that failed is the cause");
Assert.That(calls, Is.EqualTo(1), "a failed reload was retried");
}
[TestCase(false)]
[TestCase(true)]
public void RivalWritingAndRestoringTheValueStillRerunsApply(bool directory)
{
var path = PathOf("aba.cc");
Seed(path, directory, 1);
using var cache = Open(path, directory);
using var rival = Open(path, directory);
var seen = new List<int>();
var result = cache.Update<UpdateCounter>(Key, counter =>
{
seen.Add(counter!.Value);
if (seen.Count == 1)
{
Rival(rival, 5);
Rival(rival, 1);
}
return Plus(counter, 1);
});
Assert.That(seen, Is.EqualTo(new[] { 1, 1 }), "the content version missed A-B-A");
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated));
Assert.That(StoredValue(path, directory), Is.EqualTo(2));
}
[TestCase(false)]
[TestCase(true)]
public void RivalRemovalIsSeenAsNull(bool directory)
{
var path = PathOf("removal.cc");
Seed(path, directory, 1);
using var cache = Open(path, directory);
using var rival = Open(path, directory);
var seen = new List<int?>();
var result = cache.Update<UpdateCounter>(Key, counter =>
{
seen.Add(counter?.Value);
if (seen.Count == 1)
rival.Commit(batch => batch.Remove(Key));
return Plus(counter, 1);
});
Assert.That(seen, Is.EqualTo(new int?[] { 1, null }));
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated));
Assert.That(StoredValue(path, directory), Is.EqualTo(1));
}
[Test]
public void ExhaustedAttemptsReturnLostRaceInADirectoryStore()
{
var path = PathOf("lost-dir.cc");
Seed(path, true, 1);
using var cache = Open(path, true);
using var rival = Open(path, true);
var calls = 0;
var result = cache.Update<UpdateCounter>(Key, counter =>
{
Rival(rival, 100 * ++calls);
return Plus(counter, 1);
}, maxAttempts: 2);
Assert.That(calls, Is.EqualTo(2));
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.LostRace));
Assert.That(result.Document!.Value, Is.EqualTo(200));
Assert.That(StoredValue(path, true), Is.EqualTo(200));
}
[Test]
public void OneAttemptMeansOneApplyAndNonPositiveThrows()
{
var path = PathOf("one.cc");
Seed(path, false, 1);
using var cache = Open(path, false);
using var rival = Open(path, false);
var calls = 0;
var result = cache.Update<UpdateCounter>(Key, counter => { calls++; Rival(rival, 50); return Plus(counter, 1); }, maxAttempts: 1);
Assert.That(calls, Is.EqualTo(1));
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.LostRace));
Assert.That(() => cache.Update<UpdateCounter>(Key, counter => counter, maxAttempts: -1), Throws.TypeOf<ArgumentOutOfRangeException>());
Assert.That(calls, Is.EqualTo(1));
}
[TestCase(false)]
[TestCase(true)]
public void UpdatedDocumentIsTheHeldInstanceAndOwnWritesCauseNoRetry(bool directory)
{
var path = PathOf("own.cc");
Seed(path, directory, 1);
using var cache = Open(path, directory);
cache.UpsertAsync(new UpdateCounter { Name = "counter", Value = 10 }, new CultRecordHandle<UpdateCounter>(Key)).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
var calls = 0;
var first = cache.Update<UpdateCounter>(Key, counter => { calls++; return Plus(counter, 1); });
var second = cache.Update<UpdateCounter>(Key, counter => { calls++; return Plus(counter, 1); });
Assert.That(calls, Is.EqualTo(2), "the cache's own writes made a later Update retry");
Assert.That(first.Document, Is.Not.Null);
Assert.That(second.Document, Is.SameAs(cache.Get<UpdateCounter>(Key)), "Updated returned a copy, not the held instance");
Assert.That(StoredValue(path, directory), Is.EqualTo(12));
}
[Test]
public void ApplyRunsOutsideTheCacheGate()
{
var path = PathOf("gate.cc");
Seed(path, false, 1);
using var cache = Open(path, false);
var reached = false;
cache.Update<UpdateCounter>(Key, counter =>
{
reached = Task.Run(() => cache.Get<UpdateCounter>(Key)).Wait(TimeSpan.FromSeconds(10));
return Plus(counter, 1);
});
Assert.That(reached, Is.True, "another thread could not read the cache while apply ran");
}
[TestCase(false)]
[TestCase(true)]
public void ContendedLedgerStoredValueEqualsUpdatedCount(bool directory)
{
var path = PathOf("ledger.cc");
Seed(path, directory, 0);
var caches = Enumerable.Range(0, 3).Select(_ => Open(path, directory)).ToArray();
try
{
var tasks = caches.Select(cache => Task.Run(() =>
{
var updated = 0;
for (var i = 0; i < 25; i++)
if (cache.Update<UpdateCounter>(Key, counter => Plus(counter, 1)).Outcome == CultUpdateOutcome.Updated)
updated++;
return updated;
})).ToArray();
Assert.That(Task.WaitAll(tasks, TimeSpan.FromMinutes(2)), Is.True);
Assert.That(StoredValue(path, directory), Is.EqualTo(tasks.Sum(task => task.Result)), "an Updated outcome was lost or a LostRace wrote");
}
finally
{
foreach (var cache in caches)
cache.Dispose();
}
}
[Test]
public void InMemoryCacheUpdatesUnderThreadRace()
{