Fix CultCache registry alias ownership

This commit is contained in:
Meta Vens 2026-07-13 17:16:41 +01:00
commit edceef54d0
2 changed files with 192 additions and 7 deletions

View file

@ -407,8 +407,9 @@ namespace GameCult.Caching
private readonly ConcurrentDictionary<Type, CultDocumentDescriptor> _byType = new();
private readonly ConcurrentDictionary<string, CultDocumentDescriptor> _bySchemaId =
new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CultDocumentDescriptor> _bySchemaName =
private readonly ConcurrentDictionary<string, CultDocumentDescriptor[]> _bySchemaName =
new(StringComparer.Ordinal);
private readonly object _registrationGate = new();
/// <summary>
/// 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)

View file

@ -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<InvalidOperationException>()
.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<InvalidOperationException>()
.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()!;
}
}