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
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:
parent
0e84434a94
commit
240aef307b
4 changed files with 279 additions and 7 deletions
|
|
@ -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();
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
tests/GameCult.Caching.Tests/ReadmeHappyPathTests.cs
Normal file
81
tests/GameCult.Caching.Tests/ReadmeHappyPathTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue