CultCache: read names and indexes once before minting; a throwing getter refuses the whole change set
Resolve now ends in Project, the only place an admission runs a [CultName] or [CultIndex] getter on a landing record. It runs before land and before any Sequence is minted, so a throwing getter refuses the admission (loads included) with nothing written, minted or left in memory. The variant name and index rules and the indexer read the projection; Apply runs no user code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
60663f952c
commit
7362257f08
3 changed files with 388 additions and 42 deletions
|
|
@ -2465,11 +2465,82 @@ namespace GameCult.Caching
|
|||
|
||||
// Variants this admission did not write whose base chain it changed: re-resolved, same delta, same storedAt.
|
||||
public List<CultStoredDocument> Dependents { get; } = new();
|
||||
|
||||
// Name and index values of every record that lands (Admitted and Dependents), by key. Filled once by Project.
|
||||
public Dictionary<string, Projection> Projections { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// What a landing record's [CultName] and [CultIndex] getters said. Indexes holds only non-blank values.
|
||||
private sealed class Projection
|
||||
{
|
||||
public Projection(string? name, (string Alias, string Value)[] indexes)
|
||||
{
|
||||
Name = name;
|
||||
Indexes = indexes;
|
||||
}
|
||||
|
||||
public string? Name { get; }
|
||||
public (string Alias, string Value)[] Indexes { get; }
|
||||
}
|
||||
|
||||
// The only place an admission runs a name or index getter on a landing record. It runs before land and before any
|
||||
// sequence is minted, so a getter that throws refuses the whole admission with nothing written and nothing minted.
|
||||
private static void Project(VariantPlan plan)
|
||||
{
|
||||
foreach (var stored in plan.Admitted.Concat(plan.Dependents))
|
||||
{
|
||||
var descriptor = stored.Descriptor;
|
||||
string? name = null;
|
||||
if (descriptor.NameAccessor != null)
|
||||
name = Read(stored, $"[CultName] member {descriptor.NameMember}", descriptor.NameAccessor) is { Length: > 0 } read ? read : null;
|
||||
var indexes = new List<(string Alias, string Value)>();
|
||||
foreach (var pair in descriptor.IndexAccessors)
|
||||
{
|
||||
var member = descriptor.RichMembers.First(candidate => candidate.IndexAlias == pair.Key).Member.Name;
|
||||
var value = Read(stored, $"index '{pair.Key}' member {member}", pair.Value);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
indexes.Add((pair.Key, value!));
|
||||
}
|
||||
|
||||
plan.Projections[stored.Key.Value] = new Projection(name, indexes.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Read(CultStoredDocument stored, string what, Func<object, string?> getter)
|
||||
{
|
||||
try
|
||||
{
|
||||
return getter(stored.Document);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Record {stored.Key.Value} ({stored.Descriptor.SchemaName}) is refused: its {what} threw: {exception.GetBaseException().Message}", exception);
|
||||
}
|
||||
}
|
||||
|
||||
// Every admission is judged here: variants resolved, then every landing record's name and index values read once,
|
||||
// then the rules that compare them. It refuses naming the keys, and mutates nothing.
|
||||
private VariantPlan Resolve(
|
||||
IReadOnlyList<CultStoredDocument> admitted,
|
||||
IReadOnlyList<CultStoredDocument> evicted,
|
||||
CacheBackingStore? source)
|
||||
{
|
||||
var plan = ResolveVariants(admitted, evicted, source);
|
||||
Project(plan);
|
||||
// The variant rules are judged only when a variant is involved.
|
||||
if (_variantKeys.Count != 0 || admitted.Any(stored => stored.Variant != null))
|
||||
{
|
||||
RefuseVariantNameSharing(plan, evicted);
|
||||
RefuseVariantIndexSharing(plan, evicted);
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
// Resolves every admitted variant and every variant whose base chain this admission changes, base first, against
|
||||
// the post-admission set. It refuses a broken variant naming the keys, and mutates nothing.
|
||||
private VariantPlan Resolve(
|
||||
private VariantPlan ResolveVariants(
|
||||
IReadOnlyList<CultStoredDocument> admitted,
|
||||
IReadOnlyList<CultStoredDocument> evicted,
|
||||
CacheBackingStore? source)
|
||||
|
|
@ -2531,11 +2602,7 @@ namespace GameCult.Caching
|
|||
}
|
||||
|
||||
if (toResolve.Count == 0)
|
||||
{
|
||||
var unchanged = new VariantPlan(admitted);
|
||||
RefuseVariantIndexSharing(unchanged, incoming, gone);
|
||||
return unchanged;
|
||||
}
|
||||
return new VariantPlan(admitted);
|
||||
|
||||
var pending = new HashSet<string>(toResolve, StringComparer.Ordinal);
|
||||
var done = new Dictionary<string, CultStoredDocument>(StringComparer.Ordinal);
|
||||
|
|
@ -2546,7 +2613,6 @@ namespace GameCult.Caching
|
|||
var plan = new VariantPlan(admitted.Select(stored => done.TryGetValue(stored.Key.Value, out var resolved) ? resolved : stored).ToList());
|
||||
foreach (var key in toResolve.Where(key => !incoming.ContainsKey(key)))
|
||||
plan.Dependents.Add(done[key]);
|
||||
RefuseVariantIndexSharing(plan, incoming, gone);
|
||||
return plan;
|
||||
|
||||
CultStoredDocument ResolveOne(string key)
|
||||
|
|
@ -2610,21 +2676,6 @@ namespace GameCult.Caching
|
|||
var document = _codec.Deserialize(type, type, kept.Count == 0 ? payload : _codec.Overlay(payload, kept))
|
||||
?? throw new InvalidOperationException($"Variant {key} resolved to nothing.");
|
||||
|
||||
if (isVariant)
|
||||
{
|
||||
// R6: the name index maps one name to one key, so a variant that kept its base's name would shadow it.
|
||||
if (descriptor.NameAccessor?.Invoke(document) is { Length: > 0 } variantName)
|
||||
{
|
||||
for (var ancestor = baseStored; ancestor != null;
|
||||
ancestor = ancestor.Variant == null ? null : done.TryGetValue(ancestor.Variant.BaseKey, out var above) ? above : Lookup(ancestor.Variant.BaseKey))
|
||||
{
|
||||
if (descriptor.NameAccessor(ancestor.Document) == variantName)
|
||||
throw new InvalidOperationException(
|
||||
$"Variant {key} and its base {ancestor.Key.Value} are both named '{variantName}'; a variant must override its name.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stack.RemoveAt(stack.Count - 1);
|
||||
return done[key] = isVariant
|
||||
? variant.Resolved(document)
|
||||
|
|
@ -2632,20 +2683,49 @@ namespace GameCult.Caching
|
|||
}
|
||||
}
|
||||
|
||||
// R6: the name index maps one name to one key, so a variant that kept its base's name would shadow it. A variant's
|
||||
// name is its projection; an ancestor that lands is read from its projection, one that stays is read as held.
|
||||
private void RefuseVariantNameSharing(VariantPlan plan, IReadOnlyList<CultStoredDocument> evicted)
|
||||
{
|
||||
var gone = evicted.Select(stored => stored.Key.Value).ToHashSet(StringComparer.Ordinal);
|
||||
var landing = plan.Admitted.Concat(plan.Dependents).ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal);
|
||||
|
||||
CultStoredDocument? Ancestor(string key) =>
|
||||
landing.TryGetValue(key, out var landed) ? landed
|
||||
: gone.Contains(key) ? null
|
||||
: _entries.TryGetValue(key, out var held) ? held : null;
|
||||
|
||||
string? NameOf(CultStoredDocument stored) =>
|
||||
plan.Projections.TryGetValue(stored.Key.Value, out var projection)
|
||||
? projection.Name
|
||||
: stored.Descriptor.NameAccessor == null ? null : Read(stored, $"[CultName] member {stored.Descriptor.NameMember}", stored.Descriptor.NameAccessor);
|
||||
|
||||
foreach (var variant in landing.Values.Where(stored => stored.Variant != null))
|
||||
{
|
||||
if (plan.Projections[variant.Key.Value].Name is not { } variantName)
|
||||
continue;
|
||||
for (var ancestor = Ancestor(variant.Variant!.BaseKey); ancestor != null;
|
||||
ancestor = ancestor.Variant == null ? null : Ancestor(ancestor.Variant.BaseKey))
|
||||
{
|
||||
if (NameOf(ancestor) == variantName)
|
||||
throw new InvalidOperationException(
|
||||
$"Variant {variant.Key.Value} and its base {ancestor.Key.Value} are both named '{variantName}'; a variant must override its name.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The one index rule, judged on the commit's result whatever the write order: a variant may not share an indexed
|
||||
// value with another record. It reads the truthful held index (every holder of a value), minus the records this
|
||||
// commit replaces or removes, plus the records it lands. Plain-vs-plain sharing is not this rule's business.
|
||||
// A unique index maps a value to one key at lookup; a variant sharing it would be found in place of its
|
||||
// holder or lose the value, and the store would refuse to reopen.
|
||||
private void RefuseVariantIndexSharing(
|
||||
VariantPlan plan,
|
||||
IReadOnlyDictionary<string, CultStoredDocument> incoming,
|
||||
HashSet<string> gone)
|
||||
private void RefuseVariantIndexSharing(VariantPlan plan, IReadOnlyList<CultStoredDocument> evicted)
|
||||
{
|
||||
var landing = plan.Admitted.Concat(plan.Dependents).ToList();
|
||||
if (landing.All(stored => stored.Descriptor.IndexAccessors.Count == 0))
|
||||
return;
|
||||
var replaced = new HashSet<string>(incoming.Keys.Concat(gone).Concat(plan.Dependents.Select(stored => stored.Key.Value)), StringComparer.Ordinal);
|
||||
var replaced = new HashSet<string>(
|
||||
plan.Admitted.Concat(evicted).Concat(plan.Dependents).Select(stored => stored.Key.Value), StringComparer.Ordinal);
|
||||
var landingByKey = landing.ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal);
|
||||
|
||||
bool IsVariant(string key) =>
|
||||
|
|
@ -2656,12 +2736,9 @@ namespace GameCult.Caching
|
|||
var landed = new Dictionary<((Type Type, string Alias) Index, string Value), List<string>>();
|
||||
foreach (var stored in landing)
|
||||
{
|
||||
foreach (var pair in stored.Descriptor.IndexAccessors)
|
||||
foreach (var (alias, value) in plan.Projections[stored.Key.Value].Indexes)
|
||||
{
|
||||
var value = pair.Value(stored.Document);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
continue;
|
||||
var index = (stored.Descriptor.DocumentType, pair.Key);
|
||||
var index = (stored.Descriptor.DocumentType, alias);
|
||||
landedValues.Add((stored, index, value));
|
||||
if (!landed.TryGetValue((index, value), out var landedKeys))
|
||||
landed[(index, value)] = landedKeys = new List<string>();
|
||||
|
|
@ -2733,7 +2810,7 @@ namespace GameCult.Caching
|
|||
|
||||
_entries[stored.Key.Value] = stored;
|
||||
_handles.AddOrUpdate(stored.Document, new KeyBox(stored.Key));
|
||||
Index(stored);
|
||||
Index(stored, plan.Projections[stored.Key.Value]);
|
||||
if (stored.Variant == null)
|
||||
_variantKeys.Remove(stored.Key.Value);
|
||||
else
|
||||
|
|
@ -2790,19 +2867,15 @@ namespace GameCult.Caching
|
|||
}
|
||||
}
|
||||
|
||||
private void Index(CultStoredDocument stored)
|
||||
private void Index(CultStoredDocument stored, Projection projection)
|
||||
{
|
||||
var type = stored.Descriptor.DocumentType;
|
||||
if (stored.Descriptor.IsGlobal)
|
||||
_globals[type] = stored.Key.Value;
|
||||
if (stored.Descriptor.NameAccessor?.Invoke(stored.Document) is { Length: > 0 } name)
|
||||
MapOf(_names, type)[name] = stored.Key.Value;
|
||||
foreach (var pair in stored.Descriptor.IndexAccessors)
|
||||
{
|
||||
var value = pair.Value(stored.Document);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
AddHolder(_indexes, (type, pair.Key), value, stored.Key.Value);
|
||||
}
|
||||
if (projection.Name != null)
|
||||
MapOf(_names, type)[projection.Name] = stored.Key.Value;
|
||||
foreach (var (alias, value) in projection.Indexes)
|
||||
AddHolder(_indexes, (type, alias), value, stored.Key.Value);
|
||||
}
|
||||
|
||||
// Documents are mutable, so a name or index value may have changed since it was indexed: drop by key.
|
||||
|
|
|
|||
210
tests/GameCult.Caching.Tests/CultCacheProjectionTests.cs
Normal file
210
tests/GameCult.Caching.Tests/CultCacheProjectionTests.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
using R3;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// Cut 1 of docs/cultcache-publication-cut.md: an admission reads every landing record's name and index values once,
|
||||
// before land and before any sequence is minted. A getter that throws refuses the whole change set.
|
||||
[NonParallelizable]
|
||||
public class CultCacheProjectionTests
|
||||
{
|
||||
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(ProjectedRecord) });
|
||||
|
||||
private string _directory = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void Prepare()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-projection-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_directory);
|
||||
ProjectedRecord.Armed = false;
|
||||
ProjectedRecord.Reads = 0;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void Cleanup()
|
||||
{
|
||||
ProjectedRecord.Armed = false;
|
||||
if (Directory.Exists(_directory))
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
[CultDocument("tests.projected_record", "tests.projected_record.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class ProjectedRecord
|
||||
{
|
||||
public static long Reads;
|
||||
public static volatile bool Armed;
|
||||
[IgnoreMember] private string _name = string.Empty;
|
||||
[IgnoreMember] private string _code = string.Empty;
|
||||
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name
|
||||
{
|
||||
get
|
||||
{
|
||||
Interlocked.Increment(ref Reads);
|
||||
if (Armed && _name == "boom")
|
||||
throw new InvalidOperationException("name getter boom");
|
||||
return _name;
|
||||
}
|
||||
set => _name = value;
|
||||
}
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("code")]
|
||||
public string Code
|
||||
{
|
||||
get
|
||||
{
|
||||
Interlocked.Increment(ref Reads);
|
||||
if (Armed && _code == "boom")
|
||||
throw new InvalidOperationException("index getter boom");
|
||||
return _code;
|
||||
}
|
||||
set => _code = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly CultRecordKey OkKey = new("projected:ok");
|
||||
private static readonly CultRecordKey BoomKey = new("projected:boom");
|
||||
|
||||
private static ProjectedRecord Ok() => new() { Name = "ok", Code = "ok-code" };
|
||||
|
||||
private static ProjectedRecord Boom(bool name) => name ? new ProjectedRecord { Name = "boom", Code = "boom-code" } : new ProjectedRecord { Name = "boom-name", Code = "boom" };
|
||||
|
||||
private string PathOf(string name) => Path.Combine(_directory, name);
|
||||
|
||||
private static CultCache Open(string path) => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
|
||||
|
||||
private static void Put(CultCache cache, ProjectedRecord document, CultRecordKey key) =>
|
||||
cache.Commit(batch => batch.Upsert(typeof(ProjectedRecord), document, key));
|
||||
|
||||
private static long SequenceOf(CultCache cache) => cache.GetWithSequence(OkKey).Sequence;
|
||||
|
||||
private static void StageBoth(CultCacheBatch batch, ProjectedRecord boom)
|
||||
{
|
||||
batch.Upsert(typeof(ProjectedRecord), Ok(), OkKey);
|
||||
batch.Upsert(typeof(ProjectedRecord), boom, BoomKey);
|
||||
}
|
||||
|
||||
[TestCase(true, "[CultName] member Name")]
|
||||
[TestCase(false, "index 'code' member Code")]
|
||||
public void ARefusedGetterMintsNothingAndChangesNothing(bool nameThrows, string member)
|
||||
{
|
||||
using var cache = new CultCache(Registry);
|
||||
var seen = new List<long>();
|
||||
using var watch = cache.Watch<ProjectedRecord>().Subscribe(change => seen.Add(change.Sequence));
|
||||
Put(cache, new ProjectedRecord { Name = "seed", Code = "seed-code" }, new CultRecordKey("projected:seed"));
|
||||
var before = SequenceOf(cache);
|
||||
seen.Clear();
|
||||
ProjectedRecord.Armed = true;
|
||||
|
||||
var refusal = Assert.Throws<InvalidOperationException>(() => cache.Commit(batch => StageBoth(batch, Boom(nameThrows))))!;
|
||||
|
||||
ProjectedRecord.Armed = false;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(refusal.Message, Does.Contain(BoomKey.Value).And.Contain(member).And.Contain("getter boom"));
|
||||
Assert.That(SequenceOf(cache), Is.EqualTo(before), "a refused admission mints nothing");
|
||||
Assert.That(cache.Get<ProjectedRecord>(OkKey), Is.Null, "the good record did not land");
|
||||
Assert.That(cache.Get<ProjectedRecord>(BoomKey), Is.Null);
|
||||
Assert.That(cache.GetByName<ProjectedRecord>("ok"), Is.Null, "the name index holds nothing of the refused set");
|
||||
Assert.That(cache.GetByIndex<ProjectedRecord>("code", "ok-code"), Is.Null);
|
||||
Assert.That(seen, Is.Empty, "nothing was published");
|
||||
});
|
||||
|
||||
Put(cache, Ok(), OkKey);
|
||||
Assert.That(SequenceOf(cache), Is.EqualTo(before + 1), "the next write is previous + 1");
|
||||
Assert.That(seen, Is.EqualTo(new[] { before + 1 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARefusedSingleWriteMintsNothing()
|
||||
{
|
||||
using var cache = new CultCache(Registry);
|
||||
ProjectedRecord.Armed = true;
|
||||
|
||||
var refusal = Assert.Throws<InvalidOperationException>(() => Put(cache, Boom(true), BoomKey))!;
|
||||
|
||||
ProjectedRecord.Armed = false;
|
||||
Assert.That(refusal.Message, Does.Contain(BoomKey.Value));
|
||||
Assert.That(SequenceOf(cache), Is.EqualTo(0));
|
||||
Assert.That(cache.Get<ProjectedRecord>(BoomKey), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARefusedBatchLeavesAStoreByteIdenticalAndReopensWithNeitherKey()
|
||||
{
|
||||
var path = PathOf("projected.cc");
|
||||
using (var seed = Open(path))
|
||||
Put(seed, new ProjectedRecord { Name = "seed", Code = "seed-code" }, new CultRecordKey("projected:seed"));
|
||||
var before = File.ReadAllBytes(path);
|
||||
|
||||
using (var cache = Open(path))
|
||||
{
|
||||
ProjectedRecord.Armed = true;
|
||||
Assert.Throws<InvalidOperationException>(() => cache.Commit(batch => StageBoth(batch, Boom(true))));
|
||||
ProjectedRecord.Armed = false;
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
|
||||
}
|
||||
|
||||
using var reopened = Open(path);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(reopened.Get<ProjectedRecord>(OkKey), Is.Null);
|
||||
Assert.That(reopened.Get<ProjectedRecord>(BoomKey), Is.Null);
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALoadWhoseGetterThrowsRefusesTheOpenAndLeavesTheFileByteIdentical()
|
||||
{
|
||||
var path = PathOf("poisoned.cc");
|
||||
using (var seed = Open(path))
|
||||
Put(seed, Boom(true), BoomKey);
|
||||
var before = File.ReadAllBytes(path);
|
||||
|
||||
ProjectedRecord.Armed = true;
|
||||
var refusal = Assert.Throws<InvalidOperationException>(() => Open(path))!;
|
||||
ProjectedRecord.Armed = false;
|
||||
|
||||
Assert.That(refusal.Message, Does.Contain(BoomKey.Value).And.Contain("[CultName] member Name"));
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
|
||||
}
|
||||
|
||||
// A counting getter: each landing record is read once for its name and once for its index, and never again by the
|
||||
// variant rules or the indexer. A variant re-resolved by a base edit lands too, and is read once.
|
||||
[Test]
|
||||
public void EachLandedRecordIsReadOncePerGetterIncludingAVariantDependent()
|
||||
{
|
||||
using var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry));
|
||||
var baseKey = new CultRecordKey("projected:base");
|
||||
Put(cache, new ProjectedRecord { Name = "base", Code = "base-code" }, baseKey);
|
||||
cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("projected:variant"), baseKey, new[]
|
||||
{
|
||||
cache.Override<ProjectedRecord>(nameof(ProjectedRecord.Name), "variant"),
|
||||
cache.Override<ProjectedRecord>(nameof(ProjectedRecord.Code), "variant-code")
|
||||
}));
|
||||
|
||||
ProjectedRecord.Reads = 0;
|
||||
Put(cache, new ProjectedRecord { Name = "base", Code = "base-code-2" }, baseKey);
|
||||
|
||||
// Two landing records (the base, and the variant re-resolved against it), two getters each.
|
||||
Assert.That(Interlocked.Read(ref ProjectedRecord.Reads), Is.EqualTo(4));
|
||||
Assert.That(cache.GetByIndex<ProjectedRecord>("code", "base-code-2"), Is.Not.Null);
|
||||
Assert.That(cache.GetByName<ProjectedRecord>("variant"), Is.Not.Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using NUnit.Framework;
|
||||
using R3;
|
||||
using static GameCult.Networking.Tests.NetworkingTests;
|
||||
|
||||
namespace GameCult.Networking.Tests
|
||||
{
|
||||
[CultDocument("tests.publication_throwing_name", "tests.publication_throwing_name.v1")]
|
||||
[MessagePack.MessagePackObject]
|
||||
public sealed class PublicationThrowingName
|
||||
{
|
||||
[MessagePack.Key(0)]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
[MessagePack.Key(1)]
|
||||
[CultName]
|
||||
public string Name
|
||||
{
|
||||
get => Text == "boom" ? throw new InvalidOperationException("name getter boom") : Text;
|
||||
set { }
|
||||
}
|
||||
}
|
||||
|
||||
// Cache publication cuts 1 and 2 seen through CultNetDatabase: a refused or failing change never stalls the
|
||||
// publication and log of the changes after it. (Soul pass 3 probes P4 and P3.)
|
||||
[NonParallelizable]
|
||||
public sealed class CultCachePublicationIsolationTests
|
||||
{
|
||||
private static NetworkSchemaNote Note(string text) => new() { Schema = "tests.networking_note.v1", Text = text };
|
||||
private static string SchemaId => CultDocumentRegistry.Shared.GetRequired<NetworkSchemaNote>().SchemaId;
|
||||
|
||||
// An admission that a [CultName] getter refuses publishes nothing, leaves nothing in memory, and stalls nothing after it.
|
||||
[Test]
|
||||
public async Task ARefusedAdmissionLeavesNothingAndLaterChangesStillPublishAndLog()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var database = new CultNetDatabase(cache);
|
||||
var seen = new ConcurrentQueue<string?>();
|
||||
using var watch = database.Watch<NetworkSchemaNote>().Subscribe(change => seen.Enqueue(change.Document?.Text));
|
||||
var okKey = new CultRecordKey("pub:ok");
|
||||
var refusal = Assert.Throws<InvalidOperationException>(() => cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(new PublicationThrowingName { Text = "ok" }, new CultRecordHandle<PublicationThrowingName>(okKey));
|
||||
batch.Upsert(new PublicationThrowingName { Text = "boom" }, new CultRecordHandle<PublicationThrowingName>(new CultRecordKey("pub:boomname")));
|
||||
}))!;
|
||||
|
||||
var after = new CultRecordKey("pub:after");
|
||||
await database.PutAsync(after, Note("after"));
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(refusal.Message, Does.Contain("pub:boomname").And.Contain("name getter boom"));
|
||||
Assert.That(cache.Get<PublicationThrowingName>(okKey), Is.Null, "the good record of a refused batch did not land");
|
||||
Assert.That(seen, Is.EqualTo(new[] { "after" }));
|
||||
Assert.That(database.LastWriteSequence(SchemaId, after), Is.Not.Null, "a later commit was never logged");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue