A write owns the record at its key when the written type declares the record's schema id, or reads an older undeclared id of its own schema name whole (ruling own-entry-reads-whole); every other registered type's record is refused, in every cache, with or without a backing store (cache-refuses-retype). Removing a record and adding one of another type in a single batch is staged as the held record's eviction then the add, so validators, Claim, the store judges and the change journal see what they see staged apart (one-batch-retype-is-eviction-then-add); Watch<old> gets Removed and Watch<new> Added. A one-batch retype across home stores is refused by the batch-spans rule and must be staged apart. A variant rebase changes type exactly where a plain write may (variant-rebase-follows-plain-write). Store kinds outside the library get one protected per-key judge (store-kind-per-key-judge), exercised by tests/GameCult.Caching.OutsideStoreTests. Spec variants:cut_spec:cut-write-ownership-cs.r1; reports h1-h5; Soul s5 clears the merge (verdict variants:verdict:cut-write-ownership-cs.s5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
417 lines
21 KiB
C#
417 lines
21 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using GameCult.Caching.MessagePack;
|
|
using MessagePack;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Caching.Tests
|
|
{
|
|
// variants:ruling:write-ownership-by-written-type and variants:ruling:cache-refuses-retype: an upsert replaces the record stored at
|
|
// its key only when the type it writes there owns that record. Which type owns the id, and which other types a registry holds,
|
|
// change nothing; retyping a key is a removal and an add. The rule is one per-key predicate (CultDocumentRegistry.WrittenOwns)
|
|
// for a cache and a cacheless writer, in a single-file store and a directory store.
|
|
public class WriteOwnershipTests
|
|
{
|
|
// Two schemas of one shape that are never the same type.
|
|
[CultDocument("tests.wo_a", "tests.wo_a.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
internal sealed class WoA
|
|
{
|
|
[Key(0)] [CultName] public string Name = string.Empty;
|
|
[Key(1)] public int Value;
|
|
}
|
|
|
|
[CultDocument("tests.wo_b", "tests.wo_b.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
internal sealed class WoB
|
|
{
|
|
[Key(0)] [CultName] public string Name = string.Empty;
|
|
[Key(1)] public int Value;
|
|
}
|
|
|
|
private static readonly CultDocumentRegistry ARegistry = CultDocumentRegistry.ForTypes(new[] { typeof(WoA) });
|
|
private static readonly CultDocumentRegistry ABRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(WoA), typeof(WoB) });
|
|
private static readonly CultRecordKey K1 = new("k1");
|
|
private static readonly CultRecordKey K2 = new("k2");
|
|
|
|
private string _directory = string.Empty;
|
|
|
|
[SetUp]
|
|
public void CreateDirectory()
|
|
{
|
|
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-writeownership-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(_directory);
|
|
}
|
|
|
|
[TearDown]
|
|
public void DeleteDirectory()
|
|
{
|
|
if (Directory.Exists(_directory))
|
|
Directory.Delete(_directory, recursive: true);
|
|
}
|
|
|
|
public static readonly object[] StoreKinds = { false, true };
|
|
|
|
private string PathFor(bool directory) => Path.Combine(_directory, directory ? "store" : "store.cc");
|
|
|
|
private static CultCache Open(string path, bool directory, CultDocumentRegistry registry)
|
|
{
|
|
var cache = new CultCache(registry);
|
|
cache.AddBackingStore(directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path));
|
|
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
|
|
return cache;
|
|
}
|
|
|
|
private static string Fingerprint(string path)
|
|
{
|
|
using var sha = SHA256.Create();
|
|
if (File.Exists(path))
|
|
return Convert.ToHexString(sha.ComputeHash(File.ReadAllBytes(path)));
|
|
return string.Join("|", Directory.GetFiles(path, "*", SearchOption.AllDirectories)
|
|
.OrderBy(file => file, StringComparer.Ordinal)
|
|
.Select(file => Path.GetRelativePath(path, file) + ":" + Convert.ToHexString(sha.ComputeHash(File.ReadAllBytes(file)))));
|
|
}
|
|
|
|
private static Type TypeAt(string path, bool directory, CultDocumentRegistry registry, CultRecordKey key)
|
|
{
|
|
using var cache = Open(path, directory, registry);
|
|
return cache.GetStored(key)!.Descriptor.DocumentType;
|
|
}
|
|
|
|
// A store that a cache holding both types wrote: A at k1 and B at k2.
|
|
private string SeedAAtK1BAtK2(bool directory)
|
|
{
|
|
var path = PathFor(directory);
|
|
using var cache = Open(path, directory, ABRegistry);
|
|
Assert.That(cache.Commit(batch =>
|
|
{
|
|
batch.Upsert(new WoA { Name = "k1", Value = 1 }, new CultRecordHandle<WoA>(K1));
|
|
batch.Upsert(new WoB { Name = "k2", Value = 2 }, new CultRecordHandle<WoB>(K2));
|
|
}), Is.True);
|
|
return path;
|
|
}
|
|
|
|
// P1: a cache holding A and B may not replace B's record with A's, whoever else the registry declares.
|
|
[TestCaseSource(nameof(StoreKinds))]
|
|
public void ACacheRefusesToWriteOneRegisteredTypeOverAnothersRecord(bool directory)
|
|
{
|
|
var path = SeedAAtK1BAtK2(directory);
|
|
var before = Fingerprint(path);
|
|
|
|
using (var cache = Open(path, directory, ABRegistry))
|
|
{
|
|
Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch => batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle<WoA>(K2))));
|
|
Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoB)), "the cache still holds B's record");
|
|
Assert.Throws<CultSchemaConflictException>(() => cache.UpsertAsync(typeof(WoA), new WoA { Name = "k2", Value = 9 }, K2));
|
|
Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoB)), "a staged write is refused at the call, not at the flush");
|
|
}
|
|
|
|
Assert.That(Fingerprint(path), Is.EqualTo(before));
|
|
Assert.That(TypeAt(path, directory, ABRegistry, K2), Is.EqualTo(typeof(WoB)));
|
|
|
|
// The pair that differs only in the type written: B over B's own record.
|
|
using (var cache = Open(path, directory, ABRegistry))
|
|
Assert.That(cache.Commit(batch => batch.Upsert(new WoB { Name = "k2", Value = 3 }, new CultRecordHandle<WoB>(K2))), Is.True);
|
|
}
|
|
|
|
// Retype is a removal and an add, in separate commits.
|
|
[TestCaseSource(nameof(StoreKinds))]
|
|
public void ARetypeIsARemovalAndAnAddInSeparateCommits(bool directory)
|
|
{
|
|
var path = SeedAAtK1BAtK2(directory);
|
|
|
|
using (var cache = Open(path, directory, ABRegistry))
|
|
{
|
|
Assert.That(cache.Commit(batch => batch.Remove(K2)), Is.True);
|
|
Assert.That(cache.Commit(batch => batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle<WoA>(K2))), Is.True);
|
|
}
|
|
|
|
Assert.That(TypeAt(path, directory, ABRegistry, K2), Is.EqualTo(typeof(WoA)));
|
|
Assert.That(TypeAt(path, directory, ABRegistry, K1), Is.EqualTo(typeof(WoA)), "the bystander is untouched");
|
|
}
|
|
|
|
// The store is judged again where the write lands, under its lock: another writer retyped the key after this cache loaded it,
|
|
// so this cache's own-type write of the old record is now a write over a record that is not its type's.
|
|
[TestCaseSource(nameof(StoreKinds))]
|
|
public void AnotherWritersRetypeRefusesThisCachesStaleWriteUnderTheLock(bool directory)
|
|
{
|
|
var path = SeedAAtK1BAtK2(directory);
|
|
using var stale = Open(path, directory, ABRegistry);
|
|
Assert.That(stale.GetStored(K1)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoA)));
|
|
using (var other = Open(path, directory, ABRegistry))
|
|
{
|
|
Assert.That(other.Commit(batch => batch.Remove(K1)), Is.True);
|
|
Assert.That(other.Commit(batch => batch.Upsert(new WoB { Name = "k1", Value = 5 }, new CultRecordHandle<WoB>(K1))), Is.True);
|
|
}
|
|
|
|
var before = Fingerprint(path);
|
|
|
|
Assert.Throws<CultSchemaConflictException>(() => stale.Commit(batch => batch.Upsert(new WoA { Name = "k1", Value = 9 }, new CultRecordHandle<WoA>(K1))));
|
|
|
|
Assert.That(Fingerprint(path), Is.EqualTo(before));
|
|
Assert.That(TypeAt(path, directory, ABRegistry, K1), Is.EqualTo(typeof(WoB)));
|
|
}
|
|
|
|
public static readonly object[] Registries = { "none", "A", "AB", "Shared" };
|
|
|
|
private static CultDocumentRegistry? Lent(object kind) => kind switch
|
|
{
|
|
"A" => ARegistry,
|
|
"AB" => ABRegistry,
|
|
"Shared" => CultDocumentRegistry.Shared,
|
|
_ => null,
|
|
};
|
|
|
|
// P3: a cacheless write of A over B's record is refused whatever registry is lent, and the same lent or not.
|
|
[TestCaseSource(nameof(Registries))]
|
|
public void ACachelessWriteOfOneTypeOverAnothersRecordIsRefusedWhateverRegistryIsLent(object lent)
|
|
{
|
|
var path = SeedAAtK1BAtK2(directory: false);
|
|
var before = Fingerprint(path);
|
|
var store = new SingleFileMessagePackBackingStore(path);
|
|
|
|
Assert.Throws<CultSchemaConflictException>(() => store.WriteDocument(K2, new WoA { Name = "k2", Value = 9 }, registry: Lent(lent)));
|
|
Assert.That(Fingerprint(path), Is.EqualTo(before));
|
|
|
|
// WriteRecord names its entry and nothing else.
|
|
var entry = ARegistry.GetRequired<WoA>().ToCatalogEntry();
|
|
var record = new CultPersistedRecord
|
|
{
|
|
Key = K2.Value,
|
|
SchemaId = entry.SchemaId,
|
|
StoredAt = "2026-10-28T00:00:00.0000000+00:00",
|
|
Payload = MessagePackSerializer.Serialize(new WoA { Name = "k2", Value = 9 }),
|
|
};
|
|
Assert.Throws<CultSchemaConflictException>(() => store.WriteRecord(record, entry, null, Lent(lent)));
|
|
Assert.That(Fingerprint(path), Is.EqualTo(before));
|
|
|
|
// The pair that differs only in whose record it is: A over A's own, which commits for every registry.
|
|
store.WriteDocument(K1, new WoA { Name = "k1", Value = 9 }, registry: Lent(lent) ?? ARegistry);
|
|
Assert.That(TypeAt(path, false, ABRegistry, K1), Is.EqualTo(typeof(WoA)));
|
|
}
|
|
|
|
// The judge is per key, and an answer for one written type is never reused for another. Another writer left A's record at two
|
|
// keys; this stale cache holds A at one and B at the other and writes both. The key A owns lands, the key where B meets A's
|
|
// record is the one refused, whichever is judged first.
|
|
[TestCase("a-own", "b-bad", true, false)]
|
|
[TestCase("a-own", "b-bad", false, false)]
|
|
[TestCase("b-own", "a-bad", true, true)]
|
|
[TestCase("b-own", "a-bad", false, true)]
|
|
public void AStaleBatchIsJudgedPerKeyByEachKeysWrittenType(string ownKey, string badKey, bool ownFirst, bool directory)
|
|
{
|
|
var path = PathFor(directory);
|
|
var own = new CultRecordKey(ownKey);
|
|
var bad = new CultRecordKey(badKey);
|
|
using (var seed = Open(path, directory, ABRegistry))
|
|
Assert.That(seed.Commit(batch =>
|
|
{
|
|
batch.Upsert(new WoA { Name = ownKey, Value = 1 }, new CultRecordHandle<WoA>(own));
|
|
batch.Upsert(new WoB { Name = badKey, Value = 1 }, new CultRecordHandle<WoB>(bad));
|
|
}), Is.True);
|
|
using var stale = Open(path, directory, ABRegistry);
|
|
using (var other = Open(path, directory, ABRegistry))
|
|
{
|
|
Assert.That(other.Commit(batch => batch.Remove(bad)), Is.True);
|
|
Assert.That(other.Commit(batch => batch.Upsert(new WoA { Name = badKey, Value = 2 }, new CultRecordHandle<WoA>(bad))), Is.True);
|
|
}
|
|
|
|
var before = Fingerprint(path);
|
|
void StageOwn(CultCacheBatch batch) => batch.Upsert(new WoA { Name = ownKey, Value = 3 }, new CultRecordHandle<WoA>(own));
|
|
void StageBad(CultCacheBatch batch) => batch.Upsert(new WoB { Name = badKey, Value = 3 }, new CultRecordHandle<WoB>(bad));
|
|
|
|
var refusal = Assert.Throws<CultSchemaConflictException>(() => stale.Commit(batch =>
|
|
{
|
|
if (ownFirst) { StageOwn(batch); StageBad(batch); } else { StageBad(batch); StageOwn(batch); }
|
|
}))!;
|
|
|
|
Assert.That(refusal.RecordKey, Is.EqualTo(badKey));
|
|
Assert.That(refusal.Message, Does.Not.Contain($"'{ownKey}'"), "the key A owns is not among the refused");
|
|
Assert.That(Fingerprint(path), Is.EqualTo(before));
|
|
}
|
|
|
|
// A batch that removes a key is a removal of it, whatever this cache has staged there: the removal rule judges it, so a key
|
|
// another writer retyped can still be removed by the cache that staged a write of the old type.
|
|
[TestCaseSource(nameof(StoreKinds))]
|
|
public void ABatchRemovalOfAStagedKeyIsJudgedAsARemoval(bool directory)
|
|
{
|
|
var path = SeedAAtK1BAtK2(directory);
|
|
using var stale = Open(path, directory, ABRegistry);
|
|
stale.UpsertAsync(typeof(WoA), new WoA { Name = "k1", Value = 9 }, K1).GetAwaiter().GetResult();
|
|
using (var other = Open(path, directory, ABRegistry))
|
|
{
|
|
Assert.That(other.Commit(batch => batch.Remove(K1)), Is.True);
|
|
Assert.That(other.Commit(batch => batch.Upsert(new WoB { Name = "k1", Value = 5 }, new CultRecordHandle<WoB>(K1))), Is.True);
|
|
}
|
|
|
|
Assert.That(stale.Commit(batch => batch.Remove(K1)), Is.True);
|
|
|
|
using var reopened = Open(path, directory, ABRegistry);
|
|
Assert.That(reopened.GetStored(K1), Is.Null);
|
|
Assert.That(reopened.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoB)), "the bystander is untouched");
|
|
}
|
|
|
|
// The ruling's retype: one batch holds the removal and the add, in the order they were called. The removal half is judged as a
|
|
// removal (a registered type's record is removable), and the record lands at the key.
|
|
[TestCaseSource(nameof(StoreKinds))]
|
|
public void ARetypeIsOneBatchHoldingTheRemovalAndTheAdd(bool directory)
|
|
{
|
|
var path = SeedAAtK1BAtK2(directory);
|
|
|
|
using (var cache = Open(path, directory, ABRegistry))
|
|
{
|
|
Assert.That(cache.Commit(batch =>
|
|
{
|
|
batch.Remove(K2);
|
|
batch.Upsert(new WoB { Name = "k2", Value = 8 }, new CultRecordHandle<WoB>(K2));
|
|
batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle<WoA>(K2));
|
|
}), Is.True, "the key stays a replacement through a later add of the same batch");
|
|
Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoA)));
|
|
}
|
|
|
|
Assert.That(TypeAt(path, directory, ABRegistry, K2), Is.EqualTo(typeof(WoA)));
|
|
Assert.That(TypeAt(path, directory, ABRegistry, K1), Is.EqualTo(typeof(WoA)), "the bystander is untouched");
|
|
}
|
|
|
|
// The order is the meaning: a removal called after the add leaves the key removed.
|
|
[TestCaseSource(nameof(StoreKinds))]
|
|
public void ARemovalCalledAfterTheAddLeavesTheKeyRemoved(bool directory)
|
|
{
|
|
var path = SeedAAtK1BAtK2(directory);
|
|
|
|
using (var cache = Open(path, directory, ABRegistry))
|
|
{
|
|
Assert.That(cache.Commit(batch =>
|
|
{
|
|
batch.Remove(K2);
|
|
batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle<WoA>(K2));
|
|
batch.Remove(K2);
|
|
}), Is.True);
|
|
Assert.That(cache.GetStored(K2), Is.Null);
|
|
}
|
|
|
|
using var reopened = Open(path, directory, ABRegistry);
|
|
Assert.That(reopened.GetStored(K2), Is.Null);
|
|
}
|
|
|
|
// The removal half is a removal and nothing more: a record no registered type owns is not replaced by adding after its removal.
|
|
[TestCaseSource(nameof(StoreKinds))]
|
|
public void ARetypeOfARecordNoRegisteredTypeOwnsIsStillRefused(bool directory)
|
|
{
|
|
var path = SeedAAtK1BAtK2(directory);
|
|
var before = Fingerprint(path);
|
|
|
|
using (var cache = Open(path, directory, ARegistry))
|
|
{
|
|
Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch =>
|
|
{
|
|
batch.Remove(K2);
|
|
batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle<WoA>(K2));
|
|
}));
|
|
}
|
|
|
|
Assert.That(Fingerprint(path), Is.EqualTo(before));
|
|
}
|
|
|
|
// A removal of a key the cache does not hold is judged under the store's lock, and so is the add after it: another writer put
|
|
// B there, this cache never loaded it, and removing then adding A is a retype of whatever is stored.
|
|
[TestCaseSource(nameof(StoreKinds))]
|
|
public void ARetypeOfAKeyTheCacheDoesNotHoldCommitsInOneBatch(bool directory)
|
|
{
|
|
var path = PathFor(directory);
|
|
using var early = Open(path, directory, ABRegistry);
|
|
using (var other = Open(path, directory, ABRegistry))
|
|
Assert.That(other.Commit(batch => batch.Upsert(new WoB { Name = "k1", Value = 1 }, new CultRecordHandle<WoB>(K1))), Is.True);
|
|
|
|
Assert.That(early.Commit(batch =>
|
|
{
|
|
batch.Remove(K1);
|
|
batch.Upsert(new WoA { Name = "k1", Value = 2 }, new CultRecordHandle<WoA>(K1));
|
|
}), Is.True);
|
|
|
|
Assert.That(TypeAt(path, directory, ABRegistry, K1), Is.EqualTo(typeof(WoA)));
|
|
}
|
|
|
|
// A removal and an add called separately on the cache are one retype too: the add, staged after the removal, is not lost to the
|
|
// flush that judges it, and the type changes in the file.
|
|
[TestCaseSource(nameof(StoreKinds))]
|
|
public void AStagedRemovalAndAddOfAnotherTypeIsNotLostAtTheFlush(bool directory)
|
|
{
|
|
var path = SeedAAtK1BAtK2(directory);
|
|
|
|
using (var cache = Open(path, directory, ABRegistry))
|
|
{
|
|
Assert.That(cache.Remove(K2), Is.True);
|
|
cache.UpsertAsync(typeof(WoA), new WoA { Name = "k2", Value = 9 }, K2).GetAwaiter().GetResult();
|
|
cache.FlushAllBackingStores();
|
|
Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoA)));
|
|
}
|
|
|
|
Assert.That(TypeAt(path, directory, ABRegistry, K2), Is.EqualTo(typeof(WoA)));
|
|
}
|
|
|
|
[TestCaseSource(nameof(StoreKinds))]
|
|
public void AStagedRemovalOfAKeyTheCacheDoesNotHoldAndAnAddOfAnotherTypeIsNotLostAtTheFlush(bool directory)
|
|
{
|
|
var path = PathFor(directory);
|
|
using var early = Open(path, directory, ABRegistry);
|
|
using (var other = Open(path, directory, ABRegistry))
|
|
Assert.That(other.Commit(batch => batch.Upsert(new WoB { Name = "k1", Value = 1 }, new CultRecordHandle<WoB>(K1))), Is.True);
|
|
|
|
Assert.That(early.Remove(K1), Is.False, "the cache holds nothing there");
|
|
early.UpsertAsync(typeof(WoA), new WoA { Name = "k1", Value = 2 }, K1).GetAwaiter().GetResult();
|
|
early.FlushAllBackingStores();
|
|
|
|
Assert.That(TypeAt(path, directory, ABRegistry, K1), Is.EqualTo(typeof(WoA)));
|
|
}
|
|
|
|
// A removal is spent by the flush that judged it: a later add of another type, with no removal in front of it, is judged as an upsert again.
|
|
[TestCaseSource(nameof(StoreKinds))]
|
|
public void ARemovalIsSpentByTheFlushThatJudgedIt(bool directory)
|
|
{
|
|
var path = SeedAAtK1BAtK2(directory);
|
|
|
|
using var cache = Open(path, directory, ABRegistry);
|
|
Assert.That(cache.Remove(K2), Is.True);
|
|
cache.FlushAllBackingStores();
|
|
|
|
// Another writer puts B at the key this cache removed; this cache never loaded it, and stages an A there with no removal.
|
|
using (var other = Open(path, directory, ABRegistry))
|
|
Assert.That(other.Commit(batch => batch.Upsert(new WoB { Name = "k2", Value = 4 }, new CultRecordHandle<WoB>(K2))), Is.True);
|
|
cache.UpsertAsync(typeof(WoA), new WoA { Name = "k2", Value = 5 }, K2).GetAwaiter().GetResult();
|
|
|
|
Assert.Throws<CultSchemaConflictException>(() => cache.FlushAllBackingStores());
|
|
Assert.That(TypeAt(path, directory, ABRegistry, K2), Is.EqualTo(typeof(WoB)));
|
|
}
|
|
|
|
// No store, same rule: a cache holding A and B refuses one's write over the other's record, on Commit and on UpsertAsync, and
|
|
// a retype is a removal and an add, in one batch or staged apart.
|
|
[Test]
|
|
public void AStorelessCacheRefusesToWriteOneRegisteredTypeOverAnothersRecord()
|
|
{
|
|
using var cache = new CultCache(ABRegistry);
|
|
Assert.That(cache.Commit(batch => batch.Upsert(new WoB { Name = "k2", Value = 2 }, new CultRecordHandle<WoB>(K2))), Is.True);
|
|
|
|
var commit = Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch => batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle<WoA>(K2))))!;
|
|
Assert.That(commit.RecordKey, Is.EqualTo(K2.Value));
|
|
Assert.Throws<CultSchemaConflictException>(() => cache.UpsertAsync(typeof(WoA), new WoA { Name = "k2", Value = 9 }, K2));
|
|
Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoB)));
|
|
|
|
// The pair that differs only in the type written: B over B's own record.
|
|
Assert.That(cache.Commit(batch => batch.Upsert(new WoB { Name = "k2", Value = 3 }, new CultRecordHandle<WoB>(K2))), Is.True);
|
|
|
|
Assert.That(cache.Commit(batch =>
|
|
{
|
|
batch.Remove(K2);
|
|
batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle<WoA>(K2));
|
|
}), Is.True);
|
|
Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoA)));
|
|
|
|
Assert.That(cache.Remove(K2), Is.True);
|
|
cache.UpsertAsync(typeof(WoB), new WoB { Name = "k2", Value = 4 }, K2).GetAwaiter().GetResult();
|
|
Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoB)));
|
|
}
|
|
}
|
|
}
|