diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index 06af65e6..f1646299 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -71,9 +71,8 @@ which route wrote it. after subscribing with a `Sequence` at or below the read is already in the document and is dropped. CultMesh cache-record mirrors (`ObserveAsync`, `ReactiveAsync`, and `RefreshAsync` on both) do this. -- Not stale-protected: schema-alias handles (`AsSchemaAlias`) get no - `Sequence`; removals do not advance a mirror's applied `Sequence`, and the - mirrors do not surface removals. +- Not stale-protected: removals do not advance a mirror's applied `Sequence`, + and the mirrors do not surface removals. - `AddJournal` registers an ordered admission journal, for audit, replication or a derived index. The cache calls each journal under its gate, right after an admission is applied and before that admission is published, once per diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 9d0f51ef..9c422424 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -689,29 +689,16 @@ namespace GameCult.Caching if (indexes.ByType.TryGetValue(descriptor.DocumentType, out var registered)) return registered; + // One type owns a schema, whatever its declaration. Its id hashes the schema's name, version and members, so a second + // type of the same name and version is refused here whether or not it hashes alike. Other types may list the id as + // compatible beside its owner, as a v2 class does that keeps v1's class registered: a record under the id resolves to + // its owner, and to a lister only when nothing owns it. Neither depends on the order types were registered in. indexes.BySchemaName.TryGetValue(descriptor.SchemaName, out var schemaNameVersions); var schemaVersionOwner = schemaNameVersions?.FirstOrDefault(candidate => string.Equals(candidate.SchemaVersion, descriptor.SchemaVersion, StringComparison.Ordinal)); if (schemaVersionOwner != null) - { - if (IsExactWireAlias(schemaVersionOwner, descriptor)) - { - indexes.ByType[descriptor.DocumentType] = descriptor; - return descriptor; - } + throw SchemaClaimedTwice(schemaVersionOwner, descriptor); - if (string.Equals(schemaVersionOwner.SchemaId, descriptor.SchemaId, StringComparison.Ordinal)) - throw SchemaIdClaimedTwice(descriptor.SchemaId, schemaVersionOwner, descriptor); - throw DuplicateSchemaRegistration( - $"schema name '{descriptor.SchemaName}' version '{descriptor.SchemaVersion}'", - schemaVersionOwner.DocumentType, - descriptor.DocumentType); - } - - // A schema id hashes the schema's name and version, so the one other type that could own this id is the one refused or - // aliased above. Other types may list the id as compatible beside its owner, as a v2 class does that keeps v1's class - // registered: a record under the id resolves to its owner, and to a lister only when nothing owns it. Neither depends on - // the order types were registered in. indexes.ByType[descriptor.DocumentType] = descriptor; indexes.BySchemaId[descriptor.SchemaId] = descriptor; foreach (var compatibleSchemaId in descriptor.CompatibleSchemaIds) @@ -724,39 +711,17 @@ namespace GameCult.Caching return descriptor; } - // An alias is the same schema in every respect a store sees, including the ids it declares compatible: a type whose - // declaration differed would have it dropped by whichever registered second. - private static bool IsExactWireAlias( - CultDocumentDescriptor canonical, - CultDocumentDescriptor candidate) => - string.Equals(canonical.SchemaName, candidate.SchemaName, StringComparison.Ordinal) && - string.Equals(canonical.SchemaVersion, candidate.SchemaVersion, StringComparison.Ordinal) && - string.Equals(canonical.SchemaId, candidate.SchemaId, StringComparison.Ordinal) && - string.Equals(canonical.ContentHash, candidate.ContentHash, StringComparison.Ordinal) && - string.Equals(canonical.CanonicalSchemaJson, candidate.CanonicalSchemaJson, StringComparison.Ordinal) && - canonical.CompatibleSchemaIds.SequenceEqual(candidate.CompatibleSchemaIds, StringComparer.Ordinal); - - private static CultSchemaConflictException SchemaIdClaimedTwice( - string schemaId, + private static CultSchemaConflictException SchemaClaimedTwice( CultDocumentDescriptor existing, CultDocumentDescriptor claimed) => new( - $"CultCache schema id '{schemaId}' is already carried by CLR type '{existing.DocumentType.FullName}' " + - $"(schema '{existing.SchemaName}') and cannot also be claimed by '{claimed.DocumentType.FullName}' " + - $"(schema '{claimed.SchemaName}'). A schema id is owned by one registered type; others may only declare it compatible.", - schemaId, + $"CultCache schema '{claimed.SchemaName}' version '{claimed.SchemaVersion}' is already registered to CLR type " + + $"'{existing.DocumentType.FullName}' and cannot also be claimed by '{claimed.DocumentType.FullName}'. " + + "A schema is owned by one registered type; others may only declare its id compatible.", + claimed.SchemaId, new[] { existing.SchemaName, claimed.SchemaName }, string.Empty); - private static InvalidOperationException DuplicateSchemaRegistration( - string identity, - Type existingType, - Type claimedType) => - new( - $"CultCache {identity} is already registered to CLR type " + - $"'{existingType.FullName}' and cannot also be claimed by '{claimedType.FullName}'. " + - "Schema compatibility must be declared through explicit persisted-schema alias metadata."); - private sealed class RegistryIndexes { public RegistryIndexes() diff --git a/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs b/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs index 8512e275..5f60544d 100644 --- a/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs +++ b/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs @@ -33,28 +33,6 @@ public sealed class CultDocumentRegistryTests Assert.That(second, Is.SameAs(first)); } - [Test] - public void ExactWireCompatibleAliasRegistersByTypeWithOneCanonicalSchemaDescriptor() - { - var registry = new CultDocumentRegistry(); - var firstType = Emit("alias_owner", "tests.registry.alias", "v1"); - var aliasType = Emit("alias_claimant", "tests.registry.alias", "v1"); - - var canonical = registry.GetRequired(firstType); - var alias = registry.GetRequired(aliasType); - - Assert.Multiple(() => - { - Assert.That(alias.DocumentType, Is.EqualTo(aliasType)); - Assert.That(alias.SchemaId, Is.EqualTo(canonical.SchemaId)); - Assert.That(registry.GetRequired(aliasType), Is.SameAs(alias)); - Assert.That(registry.GetRequiredBySchemaId(canonical.SchemaId), Is.SameAs(canonical)); - Assert.That( - registry.AllDescriptors.Count(descriptor => descriptor.SchemaId == canonical.SchemaId), - Is.EqualTo(2)); - }); - } - [Test] public void DifferentVersionsSharingSchemaNameRegisterWithoutReflectionOrderSelection() { @@ -85,28 +63,6 @@ public sealed class CultDocumentRegistryTests .And.Message.Contains("v2")); } - [Test] - public void IncompatibleTypesClaimingSameSchemaNameAndVersionFailBeforeMutation() - { - var registry = new CultDocumentRegistry(); - var firstType = Emit("layout_owner", "tests.registry.layout_collision", "v1"); - var secondType = Emit( - "layout_claimant", - "tests.registry.layout_collision", - "v1", - new[] { new Field("Value", typeof(string), 0) }); - var first = registry.GetRequired(firstType); - - Assert.That( - () => registry.GetRequired(secondType), - Throws.TypeOf() - .With.Message.Contains("schema name 'tests.registry.layout_collision' version 'v1'") - .And.Message.Contains(firstType.FullName) - .And.Message.Contains(secondType.FullName)); - Assert.That(registry.GetRequiredBySchemaId(first.SchemaId), Is.SameAs(first)); - Assert.That(registry.AllDescriptors.Any(descriptor => descriptor.DocumentType == secondType), Is.False); - } - [Test] public void ConcurrentRefreshAndReadsObserveCompleteRegistrySnapshots() { @@ -122,10 +78,10 @@ public sealed class CultDocumentRegistryTests }); } - // A second type owning a schema id is refused, typed, naming both, whichever order the two register in, and the registry - // keeps what it had. - private static void AssertSecondClaimRefused(Type first, Type second, string schemaId) + // A second type for one schema is refused, typed, naming both and the claimed id, and the registry keeps what it had. + private static void AssertSecondClaimRefused(Type first, Type second) { + var schemaId = CultDocumentRegistry.ForTypes(new[] { second }).GetRequired(second).SchemaId; var refusal = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { first, second }))!; Assert.Multiple(() => { @@ -191,26 +147,25 @@ public sealed class CultDocumentRegistryTests Assert.That(CultDocumentRegistry.ForTypes(listers.Prepend(owner)).ResolvePersistedSchema(sharedId, catalog).DocumentType, Is.EqualTo(owner)); } - // The same schema declaring different compatible ids is not an alias: whichever registered second would lose its declaration. - [TestCase(false)] - [TestCase(true)] - public void TheSameSchemaWithADifferentDeclarationIsNotAnAlias(bool reversed) + // One type per schema, in every runtime: a second type of the same schema name and version is refused whichever registers + // first, whether it is identical to the first, declares different compatible ids, or has different members. + [TestCase("identical", false)] + [TestCase("identical", true)] + [TestCase("declaration", false)] + [TestCase("declaration", true)] + [TestCase("members", false)] + [TestCase("members", true)] + public void ASecondTypeForOneSchemaIsRefused(string difference, bool reversed) { - var bare = Emit("bare", "tests.registry.declared_alias", "v1"); - var declaring = Emit("declaring", "tests.registry.declared_alias", "v1", compatibleSchemaIds: new[] { "tests.registry.older" }); - var schemaId = CultDocumentRegistry.ForTypes(new[] { bare }).GetRequired(bare).SchemaId; + var schemaName = $"tests.registry.claimed_twice_{difference}"; + var first = Emit($"first_{difference}", schemaName, "v1"); + var second = difference switch + { + "identical" => Emit($"second_{difference}", schemaName, "v1"), + "declaration" => Emit($"second_{difference}", schemaName, "v1", compatibleSchemaIds: new[] { "tests.registry.older" }), + _ => Emit($"second_{difference}", schemaName, "v1", new[] { new Field("Value", typeof(string), 0) }), + }; - AssertSecondClaimRefused(reversed ? declaring : bare, reversed ? bare : declaring, schemaId); - } - - [Test] - public void TheSameSchemaWithTheSameDeclarationIsAnAlias() - { - var first = Emit("declaring_first", "tests.registry.same_declaration", "v1", compatibleSchemaIds: new[] { "tests.registry.same_older" }); - var second = Emit("declaring_second", "tests.registry.same_declaration", "v1", compatibleSchemaIds: new[] { "tests.registry.same_older" }); - - var registry = CultDocumentRegistry.ForTypes(new[] { first, second }); - - Assert.That(registry.GetRequired(second).SchemaId, Is.EqualTo(registry.GetRequired(first).SchemaId)); + AssertSecondClaimRefused(reversed ? second : first, reversed ? first : second); } }