Caching: a cacheless write owns a record only through a claimant it writes
Owns takes a condition on the claimant's answer (mayClaim): the cache arm accepts any
claimant, the cacheless arm only one of the entries the call writes. Another registered
type's record, under its current id or an old one it lists, is refused as at 0e57bf11.
Tests pin P1a/P1e/P1f shapes, the writer's own drift under a larger registry, and that a
second same-name type in the lent registry only refuses. CHANGELOG entry for the cacheless
claimant; stale comments on the store's owns predicate and WriteDocument's registry doc fixed.
Builds not yet run when committed; verified on Yggdrasil before push.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
50a4d73f29
commit
d3a4d0a3bc
4 changed files with 97 additions and 14 deletions
|
|
@ -208,8 +208,10 @@ loaded, is refused typed, and the record is reloaded as the store holds it, read
|
|||
read it. This holds for every write through the store, the store's own `Push`, `Delete` and
|
||||
`CommitBatch` included. A CultMesh single-file write asks the same rule: it owns its entry's own id and the ids that entry lists
|
||||
compatible, and an id whose publishing entry its claimant reads whole (no persisted slot unread). The claimant is
|
||||
the one a cache with the call's registry would pick, so a cacheless write is never more permissive than a cache;
|
||||
with no registry the call's one entry is the claimant. It is refused the same way otherwise.
|
||||
the one a cache with the call's registry would pick, and it owns the record only when it is the entry the call writes, so a
|
||||
cacheless write is never more permissive than a cache, nor than its own declaration: a record another registered type claims
|
||||
is refused, under that type's current id or an old one it lists. With no registry the call's one entry is the claimant. It is
|
||||
refused the same way otherwise.
|
||||
|
||||
A record that resolves to no local type is a foreign record, for example one of a type this build does not
|
||||
have, or one renamed under a new schema name without declaring its old id. A store never destroys or relabels it: the store carries it
|
||||
|
|
|
|||
|
|
@ -817,21 +817,24 @@ namespace GameCult.Caching
|
|||
// next write. A record read only by dropping slots is not owned: declaring its id compatible is the consent to the loss.
|
||||
// Header only: the persisted catalog entry that publishes the id is compared with the claimant's entry, never a payload. A
|
||||
// claimant that cannot be named (ambiguous, id missing from the catalog, shapes incompatible) owns nothing.
|
||||
internal bool Owns(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog) => Owns(schemaId, catalog, Declared);
|
||||
internal bool Owns(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog) => Owns(schemaId, catalog, Declared, _ => true);
|
||||
|
||||
// The same rule with the declared half supplied: a cacheless writer lent this registry declares what it writes, and claims
|
||||
// with the types this registry would.
|
||||
internal bool Owns(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog, Func<string, bool> declared) =>
|
||||
Owns(schemaId, catalog, declared, _indexes, descriptor => descriptor.ToCatalogEntry());
|
||||
// with the types this registry would, but only a claimant that is itself one of the entries it writes may own a record.
|
||||
internal bool Owns(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog, Func<string, bool> declared, Func<CultSchemaCatalogEntry, bool> mayClaim) =>
|
||||
Owns(schemaId, catalog, declared, _indexes, descriptor => descriptor.ToCatalogEntry(), mayClaim);
|
||||
|
||||
// The rule itself, for any writer: declared, or the claimant (picked by the one Claimant over a lookup) reads the record's
|
||||
// published entry whole. The lookup is a registry's indexes, or the entries a registry-less cacheless writer writes.
|
||||
// published entry whole. The lookup is a registry's indexes, or the entries a registry-less cacheless writer writes. mayClaim
|
||||
// is a condition on the claimant's answer, not a second lookup: a cache accepts any claimant, a cacheless writer only one of
|
||||
// the entries the call writes, so it never owns a record another registered type reads.
|
||||
internal static bool Owns<T>(
|
||||
string schemaId,
|
||||
IReadOnlyCollection<CultSchemaCatalogEntry> catalog,
|
||||
Func<string, bool> declared,
|
||||
IClaimantLookup<T> lookup,
|
||||
Func<T, CultSchemaCatalogEntry> entryOf)
|
||||
Func<T, CultSchemaCatalogEntry> entryOf,
|
||||
Func<CultSchemaCatalogEntry, bool> mayClaim)
|
||||
where T : class
|
||||
{
|
||||
if (declared(schemaId))
|
||||
|
|
@ -839,7 +842,10 @@ namespace GameCult.Caching
|
|||
try
|
||||
{
|
||||
var claimant = Claimant(schemaId, catalog, lookup, out var persisted);
|
||||
return persisted != null && claimant != null && ReadsWhole(persisted, entryOf(claimant));
|
||||
if (persisted == null || claimant == null)
|
||||
return false;
|
||||
var claimantEntry = entryOf(claimant);
|
||||
return mayClaim(claimantEntry) && ReadsWhole(persisted, claimantEntry);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
|
|
@ -4481,7 +4487,7 @@ namespace GameCult.Caching
|
|||
/// <param name="key">The record key the document is stored under.</param>
|
||||
/// <param name="document">The document to store.</param>
|
||||
/// <param name="storedAt">The stored-at stamp, or null for now.</param>
|
||||
/// <param name="registry">The registry that describes and encodes the document, or null for <see cref="CultDocumentRegistry.Shared"/>. It serves this call only: the store keeps no registry.</param>
|
||||
/// <param name="registry">The registry that describes and encodes the document, or null for <see cref="CultDocumentRegistry.Shared"/>. It also picks the type that reads a durable record, as a cache registering it would, and that type owns the record only when it is the one written; another registered type's record is refused. It serves this call only: the store keeps no registry.</param>
|
||||
/// <remarks>The call holds the store's gate while it runs. User code it runs (property getters while the document is encoded, constructors while a copied record is read) must not enter a cache or another store; that throws <see cref="InvalidOperationException"/>, carried by the encoder's wrapper when the code ran inside it.</remarks>
|
||||
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache.</exception>
|
||||
/// <exception cref="CultStoreUnreadableException">The file at the path is not a store this runtime can read.</exception>
|
||||
|
|
@ -4703,7 +4709,8 @@ namespace GameCult.Caching
|
|||
}
|
||||
|
||||
// A cache owns through its registry (CultDocumentRegistry.Owns). A cacheless CultMesh write owns what the one catalog entry it
|
||||
// writes declares: its own id and the ids it lists compatible, or an id whose publishing entry that entry reads whole
|
||||
// writes declares: its own id and the ids it lists compatible, or an id whose publishing entry its claimant reads whole, the
|
||||
// claimant being the one a cache with the lent registry would pick (else that entry) and only an entry the call writes
|
||||
// (variants:ruling:mesh-writes-bound-by-declaration). The keys judged are every key staged, written, removed or checked.
|
||||
private void RefuseUndeclaredOnDisk(Durable? durable, IReadOnlyCollection<CultStagedRecord> upserts, IReadOnlyCollection<string> removals, IEnumerable<string> checks) =>
|
||||
RefuseUndeclaredOnDisk(
|
||||
|
|
@ -4713,7 +4720,8 @@ namespace GameCult.Caching
|
|||
|
||||
// The owns predicate a write over the durable catalog asks. A cacheless writer declares what the entries it writes declare
|
||||
// (variants:ruling:mesh-writes-bound-by-declaration), and reads a published entry with the claimant a cache registering the
|
||||
// same types would pick: its lent registry's, else the entries it writes. It is never more permissive than that cache.
|
||||
// same types would pick (its lent registry's, else the entries it writes), owning the record only when that claimant is an
|
||||
// entry the call writes. It is never more permissive than that cache, nor than the declaration.
|
||||
private Func<string, IReadOnlyCollection<CultSchemaCatalogEntry>, bool> OwnsOnDisk(IReadOnlyCollection<CultStagedRecord>? upserts = null) =>
|
||||
Cache != null || upserts == null
|
||||
? Registry.Owns
|
||||
|
|
@ -4723,9 +4731,11 @@ namespace GameCult.Caching
|
|||
Func<string, bool> declared = candidate => writes.Any(entry =>
|
||||
string.Equals(entry.SchemaId, candidate, StringComparison.Ordinal) ||
|
||||
entry.CompatibleSchemaIds.Contains(candidate, StringComparer.Ordinal));
|
||||
Func<CultSchemaCatalogEntry, bool> written = claimant =>
|
||||
writes.Any(entry => string.Equals(entry.SchemaId, claimant.SchemaId, StringComparison.Ordinal));
|
||||
return AttachedRegistry is { } lent
|
||||
? lent.Owns(id, catalog, declared)
|
||||
: CultDocumentRegistry.Owns(id, catalog, declared, new CatalogEntryLookup(writes), entry => entry);
|
||||
? lent.Owns(id, catalog, declared, written)
|
||||
: CultDocumentRegistry.Owns(id, catalog, declared, new CatalogEntryLookup(writes), entry => entry, written);
|
||||
};
|
||||
|
||||
// A refused key's durable version is what this store has now read of it.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,17 @@ namespace GameCult.Caching.Tests
|
|||
[Key(0)] [CultName] public string Name = string.Empty;
|
||||
}
|
||||
|
||||
// Another schema with Narrow's shape that lists an old id: it claims records stored under that id.
|
||||
[CultDocument("tests.restamp_lister", "tests.restamp_lister.v1", CompatibleSchemaIds = new[] { ListerOldId })]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class NoteLister
|
||||
{
|
||||
[Key(0)] [CultName] public string Name = string.Empty;
|
||||
[Key(1)] public int Value;
|
||||
}
|
||||
|
||||
private const string ListerOldId = "tests.restamp.lister_old";
|
||||
|
||||
// Grown with the lossy id declared: declaration is the consent to the loss.
|
||||
[CultDocument("tests.restamp_note", "tests.restamp_note.v4", CompatibleSchemaIds = new[] { WideId })]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
|
|
@ -493,6 +504,65 @@ namespace GameCult.Caching.Tests
|
|||
Assert.That(Snapshot(path).Records.Single(record => record.Key == "note").SchemaId, Is.EqualTo(NarrowRegistry.GetRequired<NoteNarrow>().SchemaId));
|
||||
}
|
||||
|
||||
private static readonly CultDocumentRegistry NarrowListerRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow), typeof(NoteLister) });
|
||||
|
||||
// A store whose Narrow-shaped record sits under an id another registered type (Lister) claims: its old id, which its entry
|
||||
// lists, or its current id.
|
||||
private string SeedListerRecord(bool underCurrentId)
|
||||
{
|
||||
var path = SeedNarrow(directory: false);
|
||||
var snapshot = Snapshot(path);
|
||||
var listerId = NarrowListerRegistry.GetRequired<NoteLister>().SchemaId;
|
||||
var entry = snapshot.SchemaCatalog.Single();
|
||||
entry.SchemaId = entry.ContentHash = underCurrentId ? listerId : "tests.restamp.lister_published";
|
||||
entry.SchemaName = "tests.restamp_lister";
|
||||
entry.SchemaVersion = "tests.restamp_lister.v1";
|
||||
entry.CompatibleSchemaIds = underCurrentId ? new[] { listerId } : new[] { "tests.restamp.lister_published", ListerOldId };
|
||||
foreach (var record in snapshot.Records)
|
||||
record.SchemaId = underCurrentId ? listerId : ListerOldId;
|
||||
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
||||
return path;
|
||||
}
|
||||
|
||||
// A cacheless write owns a record only through a claimant it writes: another registered type that lists the record's id, or
|
||||
// owns it, and reads it whole keeps the record, whatever the lent registry (or Shared, with none) registers.
|
||||
// Registry null is Shared, which holds every [CultDocument] type of the test assembly.
|
||||
[TestCase(false, true)]
|
||||
[TestCase(false, false)]
|
||||
[TestCase(true, true)]
|
||||
[TestCase(true, false)]
|
||||
public void ACachelessWriterNeverOwnsARecordAnotherRegisteredTypeClaims(bool underCurrentId, bool lendRegistry)
|
||||
{
|
||||
var path = SeedListerRecord(underCurrentId);
|
||||
var before = Fingerprint(path);
|
||||
|
||||
Assert.Throws<CultSchemaConflictException>(() => WriteNarrow(path, cacheless: true, lendRegistry ? NarrowListerRegistry : CultDocumentRegistry.Shared));
|
||||
Assert.That(Fingerprint(path), Is.EqualTo(before));
|
||||
}
|
||||
|
||||
// The registry's other types change nothing about a record the written type claims itself.
|
||||
[Test]
|
||||
public void AnotherRegisteredTypeDoesNotTakeAWritersOwnDriftRecord()
|
||||
{
|
||||
var path = SeedRenamed(listsNarrow: true);
|
||||
|
||||
WriteNarrow(path, cacheless: true, NarrowListerRegistry);
|
||||
|
||||
Assert.That(Snapshot(path).Records.Single(record => record.Key == "note").SchemaId, Is.EqualTo(NarrowRegistry.GetRequired<NoteNarrow>().SchemaId));
|
||||
}
|
||||
|
||||
// What the lent registry registers beyond the written type can only make the claim more ambiguous, never own more: a second
|
||||
// version of the written schema's name refuses a record the write owned by name alone.
|
||||
[Test]
|
||||
public void ASecondVersionOfTheWrittenNameInTheLentRegistryOnlyRefuses()
|
||||
{
|
||||
var path = SeedRenamed(listsNarrow: false, name: "tests.restamp_note");
|
||||
var before = Fingerprint(path);
|
||||
|
||||
Assert.Throws<CultSchemaConflictException>(() => WriteNarrow(path, cacheless: true, CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow), typeof(NoteGrown) })));
|
||||
Assert.That(Fingerprint(path), Is.EqualTo(before));
|
||||
}
|
||||
|
||||
// A record stored under an id its catalog entry only lists (no entry owns it) is published by that entry, and the type that
|
||||
// reads the entry whole owns the record: a cache and a cacheless writer alike restamp it.
|
||||
[TestCase(false)]
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Releases as 3.0.0 (major).
|
|||
- A store is written in the format its content needs, and CultLib 2.0 refuses the newer ones by name. A single-file store holding a document variant is written as `cultcache.store.v2`, one holding an element id as `cultcache.store.v3`; a directory store holding an element id is written as `cultcache.store.v5` (`v4` otherwise). A store is marked only when a record actually holds an id or a variant. Upgrade readers before writers: a 2.0 reader that opens such a store is refused, not misread.
|
||||
- Writes are write sets. A flush or commit replaces the records it staged and copies every other record forward as stored, under the store's lock, instead of rewriting the whole store from the cache's memory. A record the cache does not know is kept, and an untouched record keeps a dropped member's slot through every write (never reuse a dropped slot; see the schema-compatibility contract).
|
||||
- A write or removal at a key whose stored record the writer does not own is refused with `CultSchemaConflictException`, and the file is left as it was. A writer owns a record whose schema id it declares (owns or lists compatible) and a record its type reads whole under compatible drift: that record is writable and is restamped under the type's own id on write, as in 2.0.1. A record a type reads only by dropping persisted slots stays read-only until its id is declared compatible, which accepts the loss (this differs from 2.0.1, which shed the slots silently). A cache owns through its registry; a cacheless CultMesh write owns through the catalog entry it writes.
|
||||
- A cacheless `WriteDocument` or `WriteRecord` picks the type that reads a stored record the way a cache registering the same types does (`WriteDocument` with no registry lends `CultDocumentRegistry.Shared`; `WriteRecord` without one takes the entry it writes), and owns the record only when that type is the entry it writes and reads it whole. Refused: a renamed entry named like the written schema but listing another registered type's id is refused as a cache refuses it; and a record another registered type claims, under that type's current id or an old id it lists, is refused and left in place. Unchanged from 2.0.1 and the earlier builds of this release: a record the written entry's own type reads whole under drift is owned and restamped. Because a registry-less `WriteDocument` reads `Shared`, loading a second type with the written schema's name makes the claim ambiguous and the write is refused (it can only refuse more, never own more); pass a registry to pin the answer.
|
||||
- Registration refuses an owned object-list element type that has no `[CultElementId]` member (a public string with `[Key]`), naming the type and the list. Element ids let a write set merge a list by identity; a list of record references or plain values is a whole value and is not affected.
|
||||
- The abstract and protected surface for store kinds outside this library changed. On `SingleFileBackingStore`, `SerializeSnapshot` and `DeserializeSnapshot` are replaced by the abstract `SerializeStore`, `SerializeRecord`, `SerializeCatalogEntry` and `ReadStore`, and the abstract `SerializePayload(object)` becomes `SerializePayload(object document, Type documentType)`: a store encodes a document as the type it is given. On `CacheBackingStore`, `Judging` is removed, `ToStoredDocument` takes the record's version (`record, version, catalog, deserialize, out report`), and these protected members are new for a store kind to implement or call: `HeaderFor`, `HeaderForWrite`, `Foreign`, `SetForeignRecords`, `Undeclared`, `RefuseUndeclared`, `RefuseUndeclaredOnDisk`, `RefuseRemovalOfUndeclared`, `Refuse`, `SameStoredIdentity`, `VersionOf`, `StoredAtOver`, `Overwrites`, `StagedChecks`, `StageChecks` and `JudgeChecks`. `MarkFlushSucceeded` is unchanged. `CacheBackingStore.ForeignRecords` is new and public: the records a store holds under a schema id its cache does not declare.
|
||||
- `CultCommitRequest.Expected` is now `IReadOnlyList<(CultRecordKey Key, string? Version)>` (it was a key with a schema id and a stored-at), `CultCommitRequest.Checks` is new, and `ConditionsHold` takes `(IReadOnlyDictionary<string, string> durable, IEnumerable<CultStoredDocument> observed, IEnumerable<CultForeignRecord> foreign)` in place of `(IReadOnlyCollection<CultPersistedRecord>, IEnumerable<CultStoredDocument>)`. A store kind outside the library lists the record versions it holds and passes its foreign records.
|
||||
|
|
|
|||
Loading…
Reference in a new issue