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:
parent
c8563e533f
commit
a9601cdd48
3 changed files with 56 additions and 5 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue