From edceef54d0a5af317379f9edf4d10acaffef3c40 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 17:16:41 +0100 Subject: [PATCH] Fix CultCache registry alias ownership --- src/GameCult.Caching/CultCache.cs | 75 ++++++++++- .../CultDocumentRegistryTests.cs | 122 ++++++++++++++++++ 2 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index e5e1d31a..1a4fa1e7 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -407,8 +407,9 @@ namespace GameCult.Caching private readonly ConcurrentDictionary _byType = new(); private readonly ConcurrentDictionary _bySchemaId = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary _bySchemaName = + private readonly ConcurrentDictionary _bySchemaName = new(StringComparer.Ordinal); + private readonly object _registrationGate = new(); /// /// Gets the shared process-wide document registry. @@ -547,9 +548,17 @@ namespace GameCult.Caching } } - if (_bySchemaName.TryGetValue(persisted.SchemaName, out var local)) + if (_bySchemaName.TryGetValue(persisted.SchemaName, out var localVersions)) { - return BuildCompatibleResolutionResult(persisted, local); + if (localVersions.Length == 1) + { + return BuildCompatibleResolutionResult(persisted, localVersions[0]); + } + + throw new InvalidOperationException( + $"CultCache schema name '{persisted.SchemaName}' is ambiguous across local versions " + + $"[{string.Join(", ", localVersions.Select(candidate => candidate.SchemaVersion).OrderBy(version => version, StringComparer.Ordinal))}]. " + + "Persisted schema compatibility must identify an explicit schema id."); } throw new InvalidOperationException( @@ -558,11 +567,65 @@ namespace GameCult.Caching private void RegisterDescriptor(CultDocumentDescriptor descriptor) { - _byType[descriptor.DocumentType] = descriptor; - _bySchemaId[descriptor.SchemaId] = descriptor; - _bySchemaName[descriptor.SchemaName] = descriptor; + lock (_registrationGate) + { + if (_byType.ContainsKey(descriptor.DocumentType)) + return; + + _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)) + { + _byType[descriptor.DocumentType] = descriptor; + return; + } + + throw DuplicateSchemaRegistration( + "schema name and version", + $"{descriptor.SchemaName}' version '{descriptor.SchemaVersion}", + schemaVersionOwner.DocumentType, + descriptor.DocumentType); + } + + if (_bySchemaId.TryGetValue(descriptor.SchemaId, out var schemaIdOwner)) + { + throw DuplicateSchemaRegistration( + "schema id", + descriptor.SchemaId, + schemaIdOwner.DocumentType, + descriptor.DocumentType); + } + + _byType[descriptor.DocumentType] = descriptor; + _bySchemaId[descriptor.SchemaId] = descriptor; + _bySchemaName[descriptor.SchemaName] = schemaNameVersions == null + ? [descriptor] + : [.. schemaNameVersions, descriptor]; + } } + 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); + + private static InvalidOperationException DuplicateSchemaRegistration( + string identityKind, + string identity, + Type existingType, + Type claimedType) => + new( + $"CultCache {identityKind} '{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 static CultDocumentDescriptor? TryBuildGeneratedDescriptor(Type type) { var definition = CultGeneratedDocumentMetadataLoader.LoadDefinitions(type.Assembly) diff --git a/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs b/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs new file mode 100644 index 00000000..c1410e52 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Reflection; +using System.Reflection.Emit; +using System.Linq; +using NUnit.Framework; + +namespace GameCult.Caching.Tests; + +[TestFixture] +public sealed class CultDocumentRegistryTests +{ + [Test] + public void IdenticalTypeRegistrationIsIdempotent() + { + var registry = new CultDocumentRegistry(); + var type = CreateDocumentType("idempotent", "tests.registry.idempotent", "v1"); + + var first = registry.GetRequired(type); + var second = registry.GetRequired(type); + + Assert.That(second, Is.SameAs(first)); + } + + [Test] + public void ExactWireCompatibleAliasRegistersByTypeWithOneCanonicalSchemaDescriptor() + { + var registry = new CultDocumentRegistry(); + var firstType = CreateDocumentType("alias_owner", "tests.registry.alias", "v1"); + var aliasType = CreateDocumentType("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() + { + var registry = new CultDocumentRegistry(); + var firstType = CreateDocumentType("version_one", "tests.registry.versioned", "v1"); + var secondType = CreateDocumentType("version_two", "tests.registry.versioned", "v2"); + + var first = registry.GetRequired(firstType); + var second = registry.GetRequired(secondType); + + Assert.Multiple(() => + { + Assert.That(registry.GetRequiredBySchemaId(first.SchemaId), Is.SameAs(first)); + Assert.That(registry.GetRequiredBySchemaId(second.SchemaId), Is.SameAs(second)); + }); + + var persisted = new CultSchemaCatalogEntry + { + SchemaId = "tests.registry.versioned.unknown", + SchemaName = "tests.registry.versioned", + SchemaVersion = "legacy" + }; + Assert.That( + () => registry.ResolvePersistedSchema(persisted.SchemaId, new[] { persisted }), + Throws.TypeOf() + .With.Message.Contains("ambiguous across local versions") + .And.Message.Contains("v1") + .And.Message.Contains("v2")); + } + + [Test] + public void IncompatibleTypesClaimingSameSchemaNameAndVersionFailBeforeMutation() + { + var registry = new CultDocumentRegistry(); + var firstType = CreateDocumentType("layout_owner", "tests.registry.layout_collision", "v1"); + var secondType = CreateDocumentType( + "layout_claimant", + "tests.registry.layout_collision", + "v1", + addStringField: true); + var first = registry.GetRequired(firstType); + + Assert.That( + () => registry.GetRequired(secondType), + Throws.TypeOf() + .With.Message.Contains("schema name and version '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); + } + + private static Type CreateDocumentType( + string typeName, + string schemaName, + string schemaVersion, + bool addStringField = false) + { + var assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName("GameCult.Caching.RegistryTests." + Guid.NewGuid().ToString("N")), + AssemblyBuilderAccess.Run); + var module = assembly.DefineDynamicModule("main"); + var type = module.DefineType( + typeName, + TypeAttributes.Class | TypeAttributes.Public | TypeAttributes.Sealed); + var attributeConstructor = typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!; + type.SetCustomAttribute(new CustomAttributeBuilder( + attributeConstructor, + new object[] { schemaName, schemaVersion })); + if (addStringField) + { + type.DefineField("Value", typeof(string), FieldAttributes.Public); + } + + return type.CreateType()!; + } +}