349 lines
12 KiB
C#
349 lines
12 KiB
C#
using System;
|
|
using Svrnty.CQRS.Events.Abstractions.Schema;
|
|
using Svrnty.CQRS.Events.Abstractions.Models;
|
|
using Svrnty.CQRS.Events.Abstractions.EventStore;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Svrnty.CQRS.Events.Abstractions;
|
|
|
|
namespace Svrnty.CQRS.Events.Schema;
|
|
|
|
/// <summary>
|
|
/// Default implementation of <see cref="ISchemaRegistry"/> with caching and automatic upcasting.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This implementation provides:
|
|
/// - In-memory caching of schema information
|
|
/// - Automatic discovery of upcaster methods (convention-based)
|
|
/// - Multi-hop upcasting through version chains
|
|
/// - Thread-safe registration and lookup
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class SchemaRegistry : ISchemaRegistry
|
|
{
|
|
private readonly ISchemaStore _store;
|
|
private readonly ILogger<SchemaRegistry> _logger;
|
|
private readonly IJsonSchemaGenerator? _jsonSchemaGenerator;
|
|
private readonly ConcurrentDictionary<string, SchemaInfo> _schemaCache = new();
|
|
private readonly ConcurrentDictionary<string, int> _latestVersionCache = new();
|
|
private readonly SemaphoreSlim _registrationLock = new(1, 1);
|
|
|
|
public SchemaRegistry(
|
|
ISchemaStore store,
|
|
ILogger<SchemaRegistry> logger,
|
|
IJsonSchemaGenerator? jsonSchemaGenerator = null)
|
|
{
|
|
_store = store ?? throw new ArgumentNullException(nameof(store));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_jsonSchemaGenerator = jsonSchemaGenerator;
|
|
}
|
|
|
|
public async Task<SchemaInfo> RegisterSchemaAsync<TEvent>(
|
|
int version,
|
|
Type? upcastFromType = null,
|
|
string? jsonSchema = null,
|
|
CancellationToken cancellationToken = default)
|
|
where TEvent : ICorrelatedEvent
|
|
{
|
|
var eventType = EventVersionAttribute.GetEventTypeName(typeof(TEvent));
|
|
var schemaId = $"{eventType}:v{version}";
|
|
|
|
// Check if already registered
|
|
if (_schemaCache.TryGetValue(schemaId, out var cached))
|
|
{
|
|
_logger.LogDebug("Schema {SchemaId} already registered (cached)", schemaId);
|
|
return cached;
|
|
}
|
|
|
|
await _registrationLock.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
// Double-check after acquiring lock
|
|
if (_schemaCache.TryGetValue(schemaId, out cached))
|
|
return cached;
|
|
|
|
// Check if already in store
|
|
var existing = await _store.GetSchemaAsync(eventType, version, cancellationToken);
|
|
if (existing != null)
|
|
{
|
|
_schemaCache[schemaId] = existing;
|
|
UpdateLatestVersionCache(eventType, version);
|
|
return existing;
|
|
}
|
|
|
|
// Generate JSON schema if not provided and generator is available
|
|
var finalJsonSchema = jsonSchema;
|
|
if (string.IsNullOrWhiteSpace(finalJsonSchema) && _jsonSchemaGenerator != null)
|
|
{
|
|
try
|
|
{
|
|
finalJsonSchema = await _jsonSchemaGenerator.GenerateSchemaAsync(
|
|
typeof(TEvent),
|
|
cancellationToken);
|
|
|
|
_logger.LogDebug(
|
|
"Auto-generated JSON schema for {EventType} v{Version}",
|
|
eventType,
|
|
version);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(
|
|
ex,
|
|
"Failed to auto-generate JSON schema for {EventType} v{Version}. Schema will be registered without JSON schema.",
|
|
eventType,
|
|
version);
|
|
}
|
|
}
|
|
|
|
// Create new schema info
|
|
var schema = new SchemaInfo(
|
|
EventType: eventType,
|
|
Version: version,
|
|
ClrType: typeof(TEvent),
|
|
JsonSchema: finalJsonSchema,
|
|
UpcastFromType: upcastFromType,
|
|
UpcastFromVersion: upcastFromType != null ? version - 1 : null,
|
|
RegisteredAt: DateTimeOffset.UtcNow);
|
|
|
|
// Validate
|
|
schema.Validate();
|
|
|
|
// Verify upcast chain if this is not version 1
|
|
if (version > 1)
|
|
{
|
|
if (upcastFromType == null)
|
|
throw new InvalidOperationException($"Version {version} must specify UpcastFromType");
|
|
|
|
// Verify previous version exists
|
|
var previousVersion = await _store.GetSchemaAsync(eventType, version - 1, cancellationToken);
|
|
if (previousVersion == null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Cannot register version {version} before version {version - 1} is registered");
|
|
}
|
|
}
|
|
|
|
// Store
|
|
await _store.StoreSchemaAsync(schema, cancellationToken);
|
|
|
|
// Cache
|
|
_schemaCache[schemaId] = schema;
|
|
UpdateLatestVersionCache(eventType, version);
|
|
|
|
_logger.LogInformation(
|
|
"Registered schema {EventType} v{Version} (CLR type: {ClrType})",
|
|
eventType,
|
|
version,
|
|
typeof(TEvent).Name);
|
|
|
|
return schema;
|
|
}
|
|
finally
|
|
{
|
|
_registrationLock.Release();
|
|
}
|
|
}
|
|
|
|
public async Task<SchemaInfo?> GetSchemaAsync(
|
|
string eventType,
|
|
int version,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var schemaId = $"{eventType}:v{version}";
|
|
|
|
// Check cache first
|
|
if (_schemaCache.TryGetValue(schemaId, out var cached))
|
|
return cached;
|
|
|
|
// Load from store
|
|
var schema = await _store.GetSchemaAsync(eventType, version, cancellationToken);
|
|
if (schema != null)
|
|
{
|
|
_schemaCache[schemaId] = schema;
|
|
}
|
|
|
|
return schema;
|
|
}
|
|
|
|
public async Task<SchemaInfo?> GetSchemaByTypeAsync(
|
|
Type clrType,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
// Try to find in cache first
|
|
var cached = _schemaCache.Values.FirstOrDefault(s => s.ClrType == clrType);
|
|
if (cached != null)
|
|
return cached;
|
|
|
|
// Get event type name and version from attribute
|
|
var versionAttr = clrType.GetCustomAttribute<EventVersionAttribute>();
|
|
if (versionAttr == null)
|
|
return null;
|
|
|
|
var eventTypeName = EventVersionAttribute.GetEventTypeName(clrType);
|
|
return await GetSchemaAsync(eventTypeName, versionAttr.Version, cancellationToken);
|
|
}
|
|
|
|
public async Task<int?> GetLatestVersionAsync(
|
|
string eventType,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
// Check cache first
|
|
if (_latestVersionCache.TryGetValue(eventType, out var cachedVersion))
|
|
return cachedVersion;
|
|
|
|
// Load from store
|
|
var latestVersion = await _store.GetLatestVersionAsync(eventType, cancellationToken);
|
|
if (latestVersion.HasValue)
|
|
{
|
|
_latestVersionCache[eventType] = latestVersion.Value;
|
|
}
|
|
|
|
return latestVersion;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<SchemaInfo>> GetSchemaHistoryAsync(
|
|
string eventType,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await _store.GetSchemaHistoryAsync(eventType, cancellationToken);
|
|
}
|
|
|
|
public async Task<ICorrelatedEvent> UpcastAsync(
|
|
ICorrelatedEvent @event,
|
|
int? targetVersion = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var currentType = @event.GetType();
|
|
var currentSchema = await GetSchemaByTypeAsync(currentType, cancellationToken);
|
|
|
|
if (currentSchema == null)
|
|
{
|
|
// Event is not versioned, return as-is
|
|
_logger.LogDebug("Event type {EventType} is not versioned, no upcasting needed", currentType.Name);
|
|
return @event;
|
|
}
|
|
|
|
var eventTypeName = currentSchema.EventType;
|
|
var currentVersion = currentSchema.Version;
|
|
|
|
// Determine target version
|
|
var actualTargetVersion = targetVersion ?? await GetLatestVersionAsync(eventTypeName, cancellationToken);
|
|
if (!actualTargetVersion.HasValue)
|
|
{
|
|
_logger.LogWarning("No versions found for event type {EventType}", eventTypeName);
|
|
return @event;
|
|
}
|
|
|
|
// Already at target version
|
|
if (currentVersion == actualTargetVersion.Value)
|
|
{
|
|
_logger.LogDebug("Event already at target version {Version}", currentVersion);
|
|
return @event;
|
|
}
|
|
|
|
// Perform multi-hop upcasting
|
|
var current = @event;
|
|
var version = currentVersion;
|
|
|
|
while (version < actualTargetVersion.Value)
|
|
{
|
|
var nextVersion = version + 1;
|
|
var nextSchema = await GetSchemaAsync(eventTypeName, nextVersion, cancellationToken);
|
|
|
|
if (nextSchema == null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Cannot upcast to version {nextVersion}: schema not found");
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"Upcasting {EventType} from v{FromVersion} to v{ToVersion}",
|
|
eventTypeName,
|
|
version,
|
|
nextVersion);
|
|
|
|
current = await UpcastSingleHopAsync(current, nextSchema, cancellationToken);
|
|
version = nextVersion;
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Successfully upcast {EventType} from v{FromVersion} to v{ToVersion}",
|
|
eventTypeName,
|
|
currentVersion,
|
|
version);
|
|
|
|
return current;
|
|
}
|
|
|
|
public async Task<bool> NeedsUpcastingAsync(
|
|
ICorrelatedEvent @event,
|
|
int? targetVersion = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var currentType = @event.GetType();
|
|
var currentSchema = await GetSchemaByTypeAsync(currentType, cancellationToken);
|
|
|
|
if (currentSchema == null)
|
|
return false; // Not versioned
|
|
|
|
var eventTypeName = currentSchema.EventType;
|
|
var actualTargetVersion = targetVersion ?? await GetLatestVersionAsync(eventTypeName, cancellationToken);
|
|
|
|
return actualTargetVersion.HasValue && currentSchema.Version < actualTargetVersion.Value;
|
|
}
|
|
|
|
private async Task<ICorrelatedEvent> UpcastSingleHopAsync(
|
|
ICorrelatedEvent fromEvent,
|
|
SchemaInfo toSchema,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var fromType = fromEvent.GetType();
|
|
var toType = toSchema.ClrType;
|
|
|
|
// Strategy 1: Look for static UpcastFrom method on target type
|
|
var upcastMethod = toType.GetMethod(
|
|
"UpcastFrom",
|
|
BindingFlags.Public | BindingFlags.Static,
|
|
null,
|
|
new[] { fromType },
|
|
null);
|
|
|
|
if (upcastMethod != null && upcastMethod.ReturnType == toType)
|
|
{
|
|
_logger.LogDebug(
|
|
"Using static UpcastFrom method: {ToType}.UpcastFrom({FromType})",
|
|
toType.Name,
|
|
fromType.Name);
|
|
|
|
var result = upcastMethod.Invoke(null, new object[] { fromEvent });
|
|
if (result is ICorrelatedEvent upcastEvent)
|
|
return upcastEvent;
|
|
|
|
throw new InvalidOperationException(
|
|
$"UpcastFrom method returned unexpected type: {result?.GetType().Name}");
|
|
}
|
|
|
|
// Strategy 2: Look for registered IEventUpcaster<TFrom, TTo> implementation
|
|
// (This would require DI integration - placeholder for now)
|
|
|
|
throw new InvalidOperationException(
|
|
$"No upcaster found for {fromType.Name} → {toType.Name}. " +
|
|
$"Add a static method: public static {toType.Name} UpcastFrom({fromType.Name} from)");
|
|
}
|
|
|
|
private void UpdateLatestVersionCache(string eventType, int version)
|
|
{
|
|
_latestVersionCache.AddOrUpdate(
|
|
eventType,
|
|
version,
|
|
(key, existing) => Math.Max(existing, version));
|
|
}
|
|
}
|