Validate: an evicted key is absent to the one-key-one-store check, so a one-batch retype across home stores is the batch-spanning refusal with its own text; contract states it; pinned

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-28 07:23:22 +01:00
commit a9601cdd48
3 changed files with 56 additions and 5 deletions

View file

@ -213,7 +213,7 @@ compatible), or when it is the type that reads the stored record's published ent
id of its own schema name that it reads whole is its own and is restamped. Anything else is refused, another registered type's record
included, and so is a record the written type would read only by dropping persisted slots. No registry enters the decision, `Shared` included:
the answer for a given written entry, stored id and catalog is the same lent a registry or not, and a cache's registered types do not make
each other's records writable. Retyping a key is a removal and an add: in one batch (the removal called before the add) or staged apart, with or without a backing store. The removal half is judged as a removal, so the key is retyped only when the removal rule allows it. A one-batch retype is staged as the held record's eviction and then the add: a watcher of the old type sees Removed and a watcher of the new type sees Added, a global's slot is freed, and every check, validator and store judge sees what it sees when the two are staged apart; this holds for plain records, variants and flattens alike (a Flatten after a Remove in the same batch flattens nothing and is refused). A variant rebased onto a base of another type follows the plain-write rule: where a plain write of the new type would own the variant's record (the new type declares the old id) the rebase commits and the variant becomes that type, and where a plain write would be refused the rebase is refused. A removal or check of a key goes to the
each other's records writable. Retyping a key is a removal and an add: in one batch (the removal called before the add) or staged apart, with or without a backing store. The removal half is judged as a removal, so the key is retyped only when the removal rule allows it. A one-batch retype is staged as the held record's eviction and then the add: a watcher of the old type sees Removed and a watcher of the new type sees Added, a global's slot is freed, and every check, validator and store judge sees what it sees when the two are staged apart; this holds for plain records, variants and flattens alike (a Flatten after a Remove in the same batch flattens nothing and is refused). A one-batch retype across two home stores is refused, by the rule that a commit lands in one home store: the removal is in the first store and the add in the second, so the batch spans both. Stage the removal and the add apart, as two commits (not atomic); the key is then in exactly one store. A variant rebased onto a base of another type follows the plain-write rule: where a plain write of the new type would own the variant's record (the new type declares the old id) the rebase commits and the variant becomes that type, and where a plain write would be refused the rebase is refused. A removal or check of a key goes to the
registry's removal rule (a record a registered type declares, or reads whole, may be removed), and a cacheless call, which has no registry,
removes a record only when an entry it writes owns it; a call that writes no entry removes nothing.

View file

@ -3107,6 +3107,7 @@ namespace GameCult.Caching
var removedKeys = removed?.ToHashSet(StringComparer.Ordinal) ?? new HashSet<string>(StringComparer.Ordinal);
var homes = new List<CacheBackingStore>();
var globals = new Dictionary<Type, string>(_globals);
var evictedKeys = evicted.Select(stored => stored.Key.Value).ToHashSet(StringComparer.Ordinal);
foreach (var stored in evicted)
{
var type = stored.Descriptor.DocumentType;
@ -3125,8 +3126,9 @@ namespace GameCult.Caching
throw new InvalidOperationException(
$"{descriptor.SchemaName} record {stored.Key.Value} was loaded from {source} but its home is {home?.ToString() ?? "no store"}.");
// One key, one store: a key never moves between homes, and two stores never both deliver it.
if (_entries.TryGetValue(stored.Key.Value, out var present) &&
// One key, one store: a key never moves between homes, and two stores never both deliver it. An evicted key is absent
// here, as it is staged apart; a retype across two home stores is then the batch spanning both (below): one rule, one text.
if (!evictedKeys.Contains(stored.Key.Value) && _entries.TryGetValue(stored.Key.Value, out var present) &&
Home(present.Descriptor.DocumentType) is var held &&
Home(descriptor.DocumentType) is var incoming &&
held != incoming)
@ -3145,7 +3147,8 @@ namespace GameCult.Caching
var distinct = homes.Distinct().ToArray();
if (distinct.Length > 1)
throw new InvalidOperationException(
$"The batch spans {distinct[0]} and {distinct[1]}; a commit lands in one home store.");
$"The batch spans {distinct[0]} and {distinct[1]}; a commit lands in one home store. " +
"To move a key between stores, stage its removal and its add apart (two commits, not one atomic commit).");
return distinct.FirstOrDefault();
void Claim(CultStoredDocument stored, bool upsert)

View file

@ -262,7 +262,55 @@ namespace GameCult.Caching.Tests
Assert.Throws<InvalidOperationException>(() => cache.FlattenAsync(K3).GetAwaiter().GetResult());
}
// finding removal-bookkeeping-unpinned, mutant X2 (a retype elsewhere in the batch skips every key's staging claim): a batch
// finding cross-store-retype-decided-by-key-home: a one-batch retype between two home stores is refused by the rule that a commit
// lands in one home store, with that rule's text (an evicted key is absent to the one-key-one-store check, as when staged
// apart); staged apart, as two commits, it leaves the key in exactly one store.
[Test]
public void ARetypeAcrossTwoHomeStoresInOneBatchIsRefusedByTheBatchSpanningRuleAndStagedApartLeavesOneStore()
{
string[] KeysIn(string path) =>
new SingleFileMessagePackBackingStore(path).ReadStoreSnapshot()?.Records.Select(record => record.Key).ToArray() ?? Array.Empty<string>();
CultCache OpenTwo(string a, string b)
{
var cache = new CultCache(AB, CultCacheMessagePack.CreateCodec(AB));
cache.AddBackingStore(new SingleFileMessagePackBackingStore(a), typeof(EvA));
cache.AddBackingStore(new SingleFileMessagePackBackingStore(b), typeof(EvB));
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
return cache;
}
var pathA = NewPath("single-file");
var pathB = NewPath("single-file");
Directory.CreateDirectory(Path.GetDirectoryName(pathA)!);
Directory.CreateDirectory(Path.GetDirectoryName(pathB)!);
using (var cache = OpenTwo(pathA, pathB))
{
PutB(cache, K1);
Assert.That(KeysIn(pathB), Is.EqualTo(new[] { "k1" }));
var refused = Assert.Throws<InvalidOperationException>(() => cache.Commit(batch =>
{
batch.Remove(K1);
batch.Upsert(typeof(EvA), new EvA { Name = "k1" }, K1);
}))!;
Assert.That(refused.Message, Does.Contain("The batch spans").And.Contain("a commit lands in one home store")
.And.Contain("stage its removal and its add apart").And.Not.Contain("second store"));
Assert.That(KeysIn(pathA), Is.Empty, "the refused batch wrote nothing");
Assert.That(KeysIn(pathB), Is.EqualTo(new[] { "k1" }));
Assert.That(cache.GetStored(K1)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvB)));
Assert.That(cache.Remove(K1), Is.True);
cache.FlushAsync().GetAwaiter().GetResult();
cache.UpsertAsync(typeof(EvA), new EvA { Name = "k1" }, K1).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
}
Assert.That(KeysIn(pathA), Is.EqualTo(new[] { "k1" }), "staged apart the key is in the add's store");
Assert.That(KeysIn(pathB), Is.Empty, "and no longer in the removal's");
}
// finding removal-bookkeeping-unpinned, mutant X2(a retype elsewhere in the batch skips every key's staging claim): a batch
// holding two retypes commits, and a cross-type add at a key it did not remove is still refused beside a retype.
[TestCaseSource(nameof(Kinds))]
public void EachKeyOfABatchIsJudgedByItsOwnRemoval(string kind)