Fix CultCache registry alias ownership
This commit is contained in:
parent
581c5d0249
commit
edceef54d0
2 changed files with 192 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
122
tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs
Normal file
122
tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs
Normal 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()!;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue