diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..6dcc30e --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,49 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET + +on: + - push + - pull_request + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.x + - name: Build + run: dotnet build + working-directory: ./src + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.x + - name: Test + run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" + working-directory: ./src/Fluss.UnitTest + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.x + - name: Run dotnet format + run: dotnet format --verify-no-changes + working-directory: ./src diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..9226fb5 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,14 @@ +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +msbuild.log +msbuild.err +msbuild.wrn \ No newline at end of file diff --git a/src/.idea/.idea.Fluss/.idea/.gitignore b/src/.idea/.idea.Fluss/.idea/.gitignore new file mode 100644 index 0000000..066e84e --- /dev/null +++ b/src/.idea/.idea.Fluss/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/.idea.Fluss.iml +/projectSettingsUpdater.xml +/modules.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/src/.idea/.idea.Fluss/.idea/encodings.xml b/src/.idea/.idea.Fluss/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/src/.idea/.idea.Fluss/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/Fluss.HotChocolate/AddExtensionMiddleware.cs b/src/Fluss.HotChocolate/AddExtensionMiddleware.cs new file mode 100644 index 0000000..9d359aa --- /dev/null +++ b/src/Fluss.HotChocolate/AddExtensionMiddleware.cs @@ -0,0 +1,211 @@ +using Fluss.Events; +using HotChocolate.AspNetCore.Subscriptions; +using HotChocolate.Execution; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RequestDelegate = HotChocolate.Execution.RequestDelegate; + +namespace Fluss.HotChocolate; + +public class AddExtensionMiddleware +{ + private const string SubsequentRequestMarker = nameof(AddExtensionMiddleware) + ".subsequentRequestMarker"; + + private readonly RequestDelegate _next; + + private readonly IServiceProvider _rootServiceProvider; + private readonly ILogger _logger; + + public AddExtensionMiddleware( + RequestDelegate next, + IServiceProvider rootServiceProvider, + ILogger logger + ) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + _rootServiceProvider = rootServiceProvider; + _logger = logger; + } + + public async ValueTask InvokeAsync(IRequestContext context) + { + await _next.Invoke(context); + + if (!context.ContextData.ContainsKey(nameof(UnitOfWork))) + { + return; + } + + if (true != context.Services.GetRequiredService().HttpContext?.WebSockets + .IsWebSocketRequest) + { + return; + } + + if (context.Request.Extensions?.ContainsKey(SubsequentRequestMarker) ?? false) + { + if (context.Result is QueryResult subsequentQueryResult) + { + context.Result = QueryResultBuilder.FromResult(subsequentQueryResult).AddContextData(nameof(UnitOfWork), + context.ContextData[nameof(UnitOfWork)]).Create(); + } + + return; + } + + if (context.Result is QueryResult qr) + { + var contextData = new Dictionary(context.ContextData); + // Do not inline; this stores a reference to the request because it is set to null on the context eventually + var contextRequest = context.Request; + context.Result = new ResponseStream(() => LiveResults(contextData, qr, contextRequest)); + } + } + + private async IAsyncEnumerable LiveResults(IReadOnlyDictionary? contextData, QueryResult firstResult, IQueryRequest originalRequest) + { + yield return firstResult; + + using var serviceScope = _rootServiceProvider.CreateScope(); + var serviceProvider = serviceScope.ServiceProvider; + + if (contextData == null) + { + _logger.LogWarning("Trying to add live results but {ContextData} is null!", nameof(contextData)); + yield break; + } + + var foundSocketSession = contextData.TryGetValue(nameof(ISocketSession), out var contextSocketSession); // as ISocketSession + var foundOperationId = contextData.TryGetValue("HotChocolate.Execution.Transport.OperationSessionId", out var operationId); // as string + + if (!foundSocketSession || !foundOperationId) + { + _logger.LogWarning("Trying to add live results but {SocketSession} or {OperationId} is not present in context!", nameof(contextSocketSession), nameof(operationId)); + yield break; + } + + if (contextSocketSession is not ISocketSession socketSession) + { + _logger.LogWarning("{ContextSocketSession} key present in context but not an {ISocketSession}!", contextSocketSession?.GetType().FullName, nameof(ISocketSession)); + yield break; + } + + while (true) + { + if (contextData == null || !contextData.ContainsKey(nameof(UnitOfWork))) + { + break; + } + + if (contextData[nameof(UnitOfWork)] is not UnitOfWork.UnitOfWork unitOfWork) + { + break; + } + + var latestPersistedEventVersion = await WaitForChange( + serviceProvider, + unitOfWork.ReadModels + ); + + if (socketSession.Operations.All(operationSession => operationSession.Id != operationId?.ToString())) + { + break; + } + + var readOnlyQueryRequest = QueryRequestBuilder + .From(originalRequest) + .AddExtension(SubsequentRequestMarker, SubsequentRequestMarker) + .AddGlobalState(UnitOfWorkParameterExpressionBuilder.PrefillUnitOfWorkVersion, + latestPersistedEventVersion) + .SetServices(serviceProvider) + .Create(); + + await using var executionResult = await serviceProvider.ExecuteRequestAsync(readOnlyQueryRequest); + + if (executionResult is not IQueryResult result) + { + break; + } + + yield return result; + contextData = executionResult.ContextData; + + if (result.Errors?.Count > 0) + { + break; + } + } + } + + /** + * Returns the received latest persistent event version after a change has occured. + */ + private static async ValueTask WaitForChange(IServiceProvider serviceProvider, IEnumerable eventListeners) + { + var currentEventListener = eventListeners.ToList(); + + var newEventNotifier = serviceProvider.GetRequiredService(); + var newTransientEventNotifier = serviceProvider.GetRequiredService(); + var eventListenerFactory = serviceProvider.GetRequiredService(); + + var cancellationTokenSource = new CancellationTokenSource(); + + var latestPersistedEventVersion = currentEventListener.Min(el => el.Tag.LastSeen); + var latestTransientEventVersion = currentEventListener.Min(el => el.Tag.LastSeenTransient); + + var persistedEventTask = Task.Run(async () => + { + while (true) + { + latestPersistedEventVersion = await newEventNotifier.WaitForEventAfter(latestPersistedEventVersion, cancellationTokenSource.Token); + + for (var index = 0; index < currentEventListener.Count; index++) + { + var eventListener = currentEventListener[index]; + var updatedEventListener = await eventListenerFactory.UpdateTo(eventListener, latestPersistedEventVersion); + + if (updatedEventListener.Tag.LastAccepted > eventListener.Tag.LastAccepted) + { + return; + } + + currentEventListener[index] = updatedEventListener; + } + } + }, cancellationTokenSource.Token); + + var transientEventTask = Task.Run(async () => + { + while (true) + { + var events = (await newTransientEventNotifier.WaitForEventAfter(latestTransientEventVersion, cancellationTokenSource.Token)).ToList(); + + for (var index = 0; index < currentEventListener.Count; index++) + { + var eventListener = currentEventListener[index]; + var updatedEventListener = eventListenerFactory.UpdateWithEvents(eventListener, events.ToPagedMemory()); + + if (updatedEventListener != eventListener) + { + return; + } + + currentEventListener[index] = updatedEventListener; + } + + latestTransientEventVersion = events.Max(el => el.Version); + } + }, cancellationTokenSource.Token); + + var completedTask = await Task.WhenAny(persistedEventTask, transientEventTask); + cancellationTokenSource.Cancel(); + + if (completedTask.IsFaulted) + { + throw completedTask.Exception!; + } + + return latestPersistedEventVersion; + } +} diff --git a/src/Fluss.HotChocolate/AutoGenerateSchema/AutoGenerateSchema.cs b/src/Fluss.HotChocolate/AutoGenerateSchema/AutoGenerateSchema.cs new file mode 100644 index 0000000..048db29 --- /dev/null +++ b/src/Fluss.HotChocolate/AutoGenerateSchema/AutoGenerateSchema.cs @@ -0,0 +1,155 @@ +using System.ComponentModel; +using System.Reflection; +using HotChocolate.Data.Filters; +using HotChocolate.Execution.Configuration; +using HotChocolate.Language; +using Microsoft.Extensions.DependencyInjection; + +namespace Fluss.HotChocolate.AutoGenerateSchema; + +public static class AutoGenerateSchema +{ + public static IRequestExecutorBuilder AutoGenerateStronglyTypedIds( + this IRequestExecutorBuilder requestExecutorBuilder, Assembly assembly) + { + var typesToGenerateFor = assembly.GetTypes().Where(t => + t.IsValueType && t.CustomAttributes.Any(a => + a.AttributeType == typeof(TypeConverterAttribute))); + + foreach (var type in typesToGenerateFor) + { + var converterType = GetStronglyTypedIdTypeForType(type); + requestExecutorBuilder.BindRuntimeType(type, converterType.MakeGenericType(type)); + } + + return requestExecutorBuilder; + } + + private static Type GetBackingType(Type type) + { + return type.GetProperty("Value")?.PropertyType ?? + throw new ArgumentException($"Could not determine backing field type for type {type.Name}"); + } + + private static Type GetStronglyTypedIdTypeForType(Type type) + { + var backingType = GetBackingType(type); + if (backingType == typeof(long)) + { + return typeof(StronglyTypedLongIdType<>); + } + + if (backingType == typeof(Guid)) + { + return typeof(StronglyTypedGuidIdType<>); + } + + throw new ArgumentException( + $"Could not find Type converter for strongly typed IDs with backing type {backingType!.Name}"); + } +} + +public abstract class StronglyTypedIdType : ScalarType + where TId : struct where TScalarType : ScalarType where TNodeType : IValueNode +{ + private readonly TScalarType scalarType; + + protected StronglyTypedIdType(TScalarType scalarType) : base(typeof(TId).Name) + { + this.scalarType = scalarType; + } + + protected override TId ParseLiteral(TNodeType valueSyntax) + { + var guid = (TCLRType)scalarType.ParseLiteral(valueSyntax)!; + + return (TId)Activator.CreateInstance(typeof(TId), guid)!; + } + + protected override TNodeType ParseValue(TId runtimeValue) + { + return (TNodeType)scalarType.ParseValue(GetInternalValue(runtimeValue)); + } + + public override IValueNode ParseResult(object? resultValue) + { + if (resultValue is TId id) + { + resultValue = GetInternalValue(id); + } + + return scalarType.ParseResult(resultValue); + } + + private TCLRType GetInternalValue(TId obj) + { + return (TCLRType)typeof(TId).GetProperty("Value")?.GetMethod?.Invoke(obj, null)!; + } + + public override bool TrySerialize(object? runtimeValue, out object? resultValue) + { + if (runtimeValue is TId id) + { + resultValue = GetInternalValue(id); + return true; + } + + return base.TrySerialize(runtimeValue, out resultValue); + } +} + +public class StronglyTypedGuidIdType : StronglyTypedIdType where TId : struct +{ + public StronglyTypedGuidIdType() : base(new UuidType('D')) { } +} + +public class StronglyTypedLongIdType : StronglyTypedIdType where TId : struct +{ + public StronglyTypedLongIdType() : base(new LongType()) { } +} + +public class StronglyTypedIdFilterConventionExtension : FilterConventionExtension +{ + protected override void Configure(IFilterConventionDescriptor descriptor) + { + base.Configure(descriptor); + + var typesToGenerateFor = typeof(TAssemblyReference).Assembly.GetTypes().Where(t => + t.IsValueType && t.CustomAttributes.Any(a => + a.AttributeType == typeof(TypeConverterAttribute))); + + + foreach (var type in typesToGenerateFor) + { + var filterInputType = typeof(StronglyTypedGuidIdFilterInput<>).MakeGenericType(type); + var nullableType = typeof(Nullable<>).MakeGenericType(type); + descriptor.BindRuntimeType(type, filterInputType); + descriptor.BindRuntimeType(nullableType, filterInputType); + } + } +} + +public class StronglyTypedGuidIdFilterInput : StringOperationFilterInputType +{ + /*public override bool TrySerialize(object? runtimeValue, out object? resultValue) { + if (runtimeValue is TId id) { + resultValue = id.ToString(); + return true; + } + + resultValue = null; + return false; + } + + public override bool TryDeserialize(object? resultValue, out object? runtimeValue) { + var canParseGuid = Guid.TryParse(resultValue?.ToString(), out var parsedGuid); + if (!canParseGuid) { + runtimeValue = null; + return false; + } + + var tId = Activator.CreateInstance(typeof(TId), parsedGuid); + runtimeValue = tId; + return true; + }*/ +} diff --git a/src/Fluss.HotChocolate/BuilderExtensions.cs b/src/Fluss.HotChocolate/BuilderExtensions.cs new file mode 100644 index 0000000..6b18de1 --- /dev/null +++ b/src/Fluss.HotChocolate/BuilderExtensions.cs @@ -0,0 +1,22 @@ +using Fluss.UnitOfWork; +using HotChocolate.Execution.Configuration; +using HotChocolate.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Fluss.HotChocolate; + +public static class BuilderExtensions +{ + public static IRequestExecutorBuilder AddLiveEventSourcing(this IRequestExecutorBuilder reb) + { + reb.UseRequest() + .RegisterService(ServiceKind.Synchronized); + + reb.Services + .AddSingleton() + .AddSingleton() + .AddSingleton(); + + return reb; + } +} diff --git a/src/Fluss.HotChocolate/Fluss.HotChocolate.csproj b/src/Fluss.HotChocolate/Fluss.HotChocolate.csproj new file mode 100644 index 0000000..e60a95d --- /dev/null +++ b/src/Fluss.HotChocolate/Fluss.HotChocolate.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/Fluss.HotChocolate/NewEventNotifier.cs b/src/Fluss.HotChocolate/NewEventNotifier.cs new file mode 100644 index 0000000..80dc82f --- /dev/null +++ b/src/Fluss.HotChocolate/NewEventNotifier.cs @@ -0,0 +1,76 @@ +using Fluss.Events; +using Fluss.Extensions; + +namespace Fluss.HotChocolate; + +public class NewEventNotifier +{ + private long _knownVersion; + private readonly List<(long startedAtVersion, SemaphoreSlim semaphoreSlim)> _listeners = new(); + private readonly SemaphoreSlim _newEventAvailable = new(0); + + public NewEventNotifier(IBaseEventRepository eventRepository) + { + _knownVersion = eventRepository.GetLatestVersion().GetResult(); + eventRepository.NewEvents += EventRepositoryOnNewEvents; + + _ = Task.Run(async () => + { + while (true) + { + await _newEventAvailable.WaitAsync(); + var newVersion = await eventRepository.GetLatestVersion(); + if (newVersion <= _knownVersion) + { + continue; + } + + lock (this) + { + _knownVersion = newVersion; + + for (var i = _listeners.Count - 1; i >= 0; i--) + { + if (_listeners[i].startedAtVersion >= newVersion) + { + continue; + } + + _listeners[i].semaphoreSlim.Release(); + _listeners.RemoveAt(i); + } + } + } + }); + } + + private void EventRepositoryOnNewEvents(object? sender, EventArgs e) + { + _newEventAvailable.Release(); + } + + public async Task WaitForEventAfter(long startedAtVersion, CancellationToken ct = default) + { + if (_knownVersion > startedAtVersion) + { + return _knownVersion; + } + + SemaphoreSlim semaphore; + + lock (this) + { + if (_knownVersion > startedAtVersion) + { + return _knownVersion; + } + + semaphore = new SemaphoreSlim(0, 1); + _listeners.Add((startedAtVersion, semaphore)); + } + + await semaphore.WaitAsync(ct); + + return _knownVersion; + } +} diff --git a/src/Fluss.HotChocolate/NewTransientEventNotifier.cs b/src/Fluss.HotChocolate/NewTransientEventNotifier.cs new file mode 100644 index 0000000..ff60976 --- /dev/null +++ b/src/Fluss.HotChocolate/NewTransientEventNotifier.cs @@ -0,0 +1,93 @@ +using Fluss.Events.TransientEvents; + +namespace Fluss.HotChocolate; + +public class NewTransientEventNotifier +{ + private readonly List<(long startedAtVersion, TaskCompletionSource> task)> _listeners = new(); + private readonly TransientEventAwareEventRepository _transientEventRepository; + + private readonly SemaphoreSlim _newEventAvailable = new(0); + + public NewTransientEventNotifier(TransientEventAwareEventRepository transientEventRepository) + { + _transientEventRepository = transientEventRepository; + transientEventRepository.NewTransientEvents += OnNewTransientEvents; + + _ = Task.Run(async () => + { + while (true) + { + await _newEventAvailable.WaitAsync(); + var events = transientEventRepository.GetCurrentTransientEvents(); + + lock (this) + { + for (var i = _listeners.Count - 1; i >= 0; i--) + { + var listener = _listeners[i]; + var newEvents = new List(); + foreach (var memory in events) + { + foreach (var eventEnvelope in memory.ToArray()) + { + if (eventEnvelope.Version <= listener.startedAtVersion) + { + continue; + } + newEvents.Add((TransientEventEnvelope)eventEnvelope); + } + } + + // If a listener is re-added before all other ones are handled, there might be a situation where + // there are no new events for that listener; in that case we keep it around + if (newEvents.Count == 0) continue; + + _listeners[i].task.SetResult(newEvents); + _listeners.RemoveAt(i); + } + } + } + }); + } + + private void OnNewTransientEvents(object? sender, EventArgs e) + { + _newEventAvailable.Release(); + } + + public async ValueTask> WaitForEventAfter(long startedAtVersion, CancellationToken ct = default) + { + var events = _transientEventRepository.GetCurrentTransientEvents(); + + if (events.LastOrDefault() is { Length: > 0 } lastEventPage && lastEventPage.Span[^1] is { } lastKnown && lastKnown.Version > startedAtVersion) + { + var newEvents = new List(); + foreach (var memory in events) + { + for (var index = 0; index < memory.Span.Length; index++) + { + var eventEnvelope = memory.Span[index]; + if (eventEnvelope.Version <= startedAtVersion) + { + continue; + } + + newEvents.Add((TransientEventEnvelope)eventEnvelope); + } + } + + return newEvents; + } + + TaskCompletionSource> task; + + lock (this) + { + task = new TaskCompletionSource>(); + _listeners.Add((startedAtVersion, task)); + } + + return await task.Task.WaitAsync(ct); + } +} diff --git a/src/Fluss.HotChocolate/UnitOfWorkParameterExpressionBuilder.cs b/src/Fluss.HotChocolate/UnitOfWorkParameterExpressionBuilder.cs new file mode 100644 index 0000000..0298561 --- /dev/null +++ b/src/Fluss.HotChocolate/UnitOfWorkParameterExpressionBuilder.cs @@ -0,0 +1,72 @@ +using System.Linq.Expressions; +using System.Reflection; +using HotChocolate.Internal; +using HotChocolate.Resolvers; + +namespace Fluss.HotChocolate; + +public class UnitOfWorkParameterExpressionBuilder : IParameterExpressionBuilder +{ + public const string PrefillUnitOfWorkVersion = nameof(AddExtensionMiddleware) + ".prefillUnitOfWorkVersion"; + + private static readonly MethodInfo GetOrSetGlobalStateUnitOfWorkMethod = + typeof(ResolverContextExtensions).GetMethods() + .First(m => m.Name == nameof(ResolverContextExtensions.GetOrSetGlobalState)) + .MakeGenericMethod(typeof(UnitOfWork.UnitOfWork)); + + private static readonly MethodInfo GetGlobalStateOrDefaultLongMethod = + typeof(ResolverContextExtensions).GetMethods() + .First(m => m.Name == nameof(ResolverContextExtensions.GetGlobalStateOrDefault)) + .MakeGenericMethod(typeof(long?)); + + private static readonly MethodInfo ServiceUnitOfWorkMethod = + typeof(IPureResolverContext).GetMethods().First( + method => method.Name == nameof(IPureResolverContext.Service) && + method.IsGenericMethod) + .MakeGenericMethod(typeof(UnitOfWork.UnitOfWork)); + + private static readonly MethodInfo GetValueOrDefaultMethod = + typeof(CollectionExtensions).GetMethods().First(m => m.Name == nameof(CollectionExtensions.GetValueOrDefault) && m.GetParameters().Length == 2); + + private static readonly MethodInfo WithPrefilledVersionMethod = + typeof(UnitOfWork.UnitOfWork).GetMethods(BindingFlags.Instance | BindingFlags.Public) + .First(m => m.Name == nameof(UnitOfWork.UnitOfWork.WithPrefilledVersion)); + + private static readonly PropertyInfo ContextData = + typeof(IHasContextData).GetProperty( + nameof(IHasContextData.ContextData))!; + + public bool CanHandle(ParameterInfo parameter) => typeof(UnitOfWork.UnitOfWork) == parameter.ParameterType; + + /* + * Produces something like this: context.GetOrSetGlobalState( + * nameof(UnitOfWork.UnitOfWork), + * _ => + * context + * .Service() + * .WithPrefilledVersion( + * context.GetGlobalState(PrefillUnitOfWorkVersion) + * ))!; + */ + public Expression Build(ParameterExpressionBuilderContext builderContext) + { + var context = builderContext.ResolverContext; + var getNewUnitOfWork = Expression.Call( + Expression.Call(context, ServiceUnitOfWorkMethod), + WithPrefilledVersionMethod, + Expression.Call( + null, + GetGlobalStateOrDefaultLongMethod, + context, + Expression.Constant(PrefillUnitOfWorkVersion))); + + return Expression.Call(null, GetOrSetGlobalStateUnitOfWorkMethod, context, Expression.Constant(nameof(UnitOfWork)), + Expression.Lambda>( + getNewUnitOfWork, + Expression.Parameter(typeof(string)))); + } + + public ArgumentKind Kind => ArgumentKind.Custom; + public bool IsPure => true; + public bool IsDefaultHandler => false; +} diff --git a/src/Fluss.PostgreSQL/ActivitySource.cs b/src/Fluss.PostgreSQL/ActivitySource.cs new file mode 100644 index 0000000..f95837f --- /dev/null +++ b/src/Fluss.PostgreSQL/ActivitySource.cs @@ -0,0 +1,26 @@ +using OpenTelemetry.Trace; + +namespace Fluss.PostgreSQL; + +internal class ActivitySource +{ + public static System.Diagnostics.ActivitySource Source { get; } = new(GetName(), GetVersion()); + + public static string GetName() + => typeof(ActivitySource).Assembly.GetName().Name!; + + private static string GetVersion() + => typeof(ActivitySource).Assembly.GetName().Version!.ToString(); +} + +public static class TracerProviderBuilderExtensions +{ + public static TracerProviderBuilder AddPostgreSQLESInstrumentation( + this TracerProviderBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.AddSource(ActivitySource.GetName()); + return builder; + } +} diff --git a/src/Fluss.PostgreSQL/Fluss.PostgreSQL.csproj b/src/Fluss.PostgreSQL/Fluss.PostgreSQL.csproj new file mode 100644 index 0000000..46d2792 --- /dev/null +++ b/src/Fluss.PostgreSQL/Fluss.PostgreSQL.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + ..\..\..\..\..\.nuget\packages\opentelemetry.api\1.6.0\lib\net6.0\OpenTelemetry.Api.dll + + + + + + + + diff --git a/src/Fluss.PostgreSQL/Migrations/01_AddEvents.cs b/src/Fluss.PostgreSQL/Migrations/01_AddEvents.cs new file mode 100644 index 0000000..b47a0f2 --- /dev/null +++ b/src/Fluss.PostgreSQL/Migrations/01_AddEvents.cs @@ -0,0 +1,33 @@ +using FluentMigrator; + +namespace EventSourcing.PostgreSQL.Migrations +{ + [Migration(1)] + public class AddEvent : Migration + { + public override void Up() + { + Create.Table("Events") + .WithColumn("Version").AsInt64().PrimaryKey().Identity() + .WithColumn("At").AsDateTimeOffset().NotNullable() + .WithColumn("By").AsGuid().Nullable() + .WithColumn("Event").AsCustom("jsonb").NotNullable(); + + Execute.Sql(@"CREATE FUNCTION notify_events() + RETURNS trigger AS + $BODY$ + BEGIN + NOTIFY new_event; + RETURN NULL; + END; + $BODY$ LANGUAGE plpgsql; + + CREATE TRIGGER new_event AFTER INSERT ON ""Events"" EXECUTE PROCEDURE notify_events();"); + } + + public override void Down() + { + Delete.Table("Events"); + } + } +} diff --git a/src/Fluss.PostgreSQL/Migrations/02_VersionConstraintIsDeferrable.cs b/src/Fluss.PostgreSQL/Migrations/02_VersionConstraintIsDeferrable.cs new file mode 100644 index 0000000..03bb5f3 --- /dev/null +++ b/src/Fluss.PostgreSQL/Migrations/02_VersionConstraintIsDeferrable.cs @@ -0,0 +1,20 @@ +using FluentMigrator; + +namespace EventSourcing.PostgreSQL.Migrations +{ + [Migration(2)] + public class VersionConstraintIsDeferrable : Migration + { + public override void Up() + { + Execute.Sql(@"ALTER TABLE ""Events"" DROP CONSTRAINT ""PK_Events"";"); + Execute.Sql(@"ALTER TABLE ""Events"" ADD CONSTRAINT ""PK_Events"" PRIMARY KEY (""Version"") DEFERRABLE INITIALLY IMMEDIATE;"); + } + + public override void Down() + { + Execute.Sql(@"ALTER TABLE ""Events"" DROP CONSTRAINT ""PK_Events"";"); + Execute.Sql(@"ALTER TABLE ""Events"" ADD CONSTRAINT ""PK_Events"" PRIMARY KEY (""Version"") NOT DEFERRABLE;"); + } + } +} diff --git a/src/Fluss.PostgreSQL/PostgreSQLEventRepository.cs b/src/Fluss.PostgreSQL/PostgreSQLEventRepository.cs new file mode 100644 index 0000000..f304277 --- /dev/null +++ b/src/Fluss.PostgreSQL/PostgreSQLEventRepository.cs @@ -0,0 +1,225 @@ +using System.Collections.ObjectModel; +using System.Data; +using System.Diagnostics; +using EventSourcing.PostgreSQL; +using Fluss.Events; +using Fluss.Exceptions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Npgsql; +using NpgsqlTypes; + +namespace Fluss.PostgreSQL; + +public partial class PostgreSQLEventRepository : IBaseEventRepository +{ + private readonly PostgreSQLConfig config; + private readonly NpgsqlDataSource dataSource; + + public PostgreSQLEventRepository(PostgreSQLConfig config) + { + this.config = config; + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(config.ConnectionString); + dataSourceBuilder.UseJsonNet(settings: new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.All, + TypeNameAssemblyFormatHandling = TypeNameAssemblyFormatHandling.Full, + MetadataPropertyHandling = + MetadataPropertyHandling.ReadAhead // While this is marked as a performance hit, profiling approves + }); + this.dataSource = dataSourceBuilder.Build(); + } + + private async ValueTask Publish(IEnumerable envelopes, Func eventExtractor, + NpgsqlConnection? conn = null) where TEnvelope : Envelope + { + using var activity = ActivitySource.Source.StartActivity(); + activity?.SetTag("EventSourcing.EventRepository", nameof(PostgreSQLEventRepository)); + + // await using var connection has the side-effect that our connection passed from the outside is also disposed, so we split this up. + await using var freshConnection = dataSource.OpenConnection(); + var connection = conn ?? freshConnection; + + activity?.AddEvent(new ActivityEvent("Connection open")); + + await using var writer = + connection.BeginBinaryImport( + @"COPY ""Events"" (""Version"", ""At"", ""By"", ""Event"") FROM STDIN (FORMAT BINARY)"); + + activity?.AddEvent(new ActivityEvent("Got Writer")); + + try + { + foreach (var eventEnvelope in envelopes.OrderBy(e => e.Version)) + { + // ReSharper disable MethodHasAsyncOverload + writer.StartRow(); + writer.Write(eventEnvelope.Version); + writer.Write(eventEnvelope.At); + if (eventEnvelope.By != null) + { + writer.Write(eventEnvelope.By.Value); + } + else + { + writer.Write(DBNull.Value); + } + + writer.Write(eventExtractor(eventEnvelope), NpgsqlDbType.Jsonb); + // ReSharper enable MethodHasAsyncOverload + } + + await writer.CompleteAsync(); + } + catch (PostgresException e) + { + if (e is { SqlState: "23505", TableName: "Events" }) + { + throw new RetryException(); + } + + throw; + } + + NotifyNewEvents(); + } + + public async ValueTask Publish(IEnumerable envelopes) + { + await Publish(envelopes, e => e.Event); + } + + private async ValueTask WithReader(long fromExclusive, long toInclusive, + Func> action) + { + await using var connection = dataSource.OpenConnection(); + await using var cmd = + new NpgsqlCommand( + """ + SELECT "Version", "At", "By", "Event" FROM "Events" WHERE "Version" > @from AND "Version" <= @to ORDER BY "Version" + """, + connection); + + cmd.Parameters.AddWithValue("@from", fromExclusive); + cmd.Parameters.AddWithValue("@to", toInclusive); + await cmd.PrepareAsync(); + + await using var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess); + + var result = await action(reader); + + await reader.CloseAsync(); + return result; + } + + public async ValueTask>> GetEvents(long fromExclusive, + long toInclusive) + { + using var activity = ActivitySource.Source.StartActivity(); + activity?.SetTag("EventSourcing.EventRepository", nameof(PostgreSQLEventRepository)); + activity?.SetTag("EventSourcing.EventRequest", $"{fromExclusive}-{toInclusive}"); + + return await WithReader(fromExclusive, toInclusive, async reader => + { + var envelopes = new List(); + + while (await reader.ReadAsync()) + { + envelopes.Add(new EventEnvelope + { + Version = reader.GetInt64(0), + At = reader.GetDateTime(1), + By = reader.IsDBNull(2) ? null : reader.GetGuid(2), + Event = reader.GetFieldValue(3) + }); + } + + return envelopes.ToPagedMemory(); + }); + } + + public async ValueTask> GetRawEvents() + { + var latestVersion = await GetLatestVersion(); + return await WithReader(-1, latestVersion, async reader => + { + var envelopes = new List(); + + while (await reader.ReadAsync()) + { + envelopes.Add(new RawEventEnvelope + { + Version = reader.GetInt64(0), + At = reader.GetDateTime(1), + By = reader.IsDBNull(2) ? null : reader.GetGuid(2), + RawEvent = reader.GetFieldValue(3), + }); + } + + return envelopes; + }); + } + + public async ValueTask ReplaceEvent(long at, IEnumerable newEnvelopes) + { + var envelopes = newEnvelopes.ToList(); + + await using var connection = dataSource.OpenConnection(); + await using var transaction = connection.BeginTransaction(); + + await using var deleteCommand = + new NpgsqlCommand("""DELETE FROM "Events" WHERE "Version" = @at;""", connection); + deleteCommand.Parameters.AddWithValue("at", at); + await deleteCommand.ExecuteNonQueryAsync(); + + if (envelopes.Count != 1) + { + // Deferring constraints to allow updating the primary key and shifting the versions + await using var deferConstraintsCommand = + new NpgsqlCommand(@"SET CONSTRAINTS ""PK_Events"" DEFERRED;", connection); + await deferConstraintsCommand.ExecuteNonQueryAsync(); + + await using var versionUpdateCommand = + new NpgsqlCommand( + """UPDATE "Events" e SET "Version" = e."Version" + @offset WHERE e."Version" > @at;""", + connection); + + versionUpdateCommand.Parameters.AddWithValue("offset", envelopes.Count - 1); + versionUpdateCommand.Parameters.AddWithValue("at", at); + await versionUpdateCommand.ExecuteNonQueryAsync(); + } + + await Publish(envelopes, e => e.RawEvent, connection); + + await transaction.CommitAsync(); + } + + public async ValueTask GetLatestVersion() + { + using var activity = ActivitySource.Source.StartActivity(); + activity?.SetTag("EventSourcing.EventRepository", nameof(PostgreSQLEventRepository)); + + await using var connection = dataSource.OpenConnection(); + + await using var cmd = new NpgsqlCommand("""SELECT MAX("Version") FROM "Events";""", connection); + await cmd.PrepareAsync(); + var scalar = await cmd.ExecuteScalarAsync(); + + if (scalar is DBNull) + { + return -1; + } + + return (long)(scalar ?? -1); + } + + public void Dispose() + { + if (!_cancellationTokenSource.IsCancellationRequested) + { + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + } + } +} diff --git a/src/Fluss.PostgreSQL/PostgreSQLEventRepositorySubscriptions.cs b/src/Fluss.PostgreSQL/PostgreSQLEventRepositorySubscriptions.cs new file mode 100644 index 0000000..52078f4 --- /dev/null +++ b/src/Fluss.PostgreSQL/PostgreSQLEventRepositorySubscriptions.cs @@ -0,0 +1,63 @@ +using Npgsql; + +namespace Fluss.PostgreSQL; + +public partial class PostgreSQLEventRepository : IDisposable +{ + private readonly CancellationTokenSource _cancellationTokenSource = new(); + private EventHandler? _newEvents; + + private bool _triggerInitialized; + + public event EventHandler NewEvents + { + add + { + _newEvents += value; +#pragma warning disable 4014 + InitializeTrigger(); +#pragma warning restore 4014 + } + + remove + { + _newEvents -= value; + } + } + + private async Task InitializeTrigger() + { + if (_triggerInitialized) + { + return; + } + + _triggerInitialized = true; + await using var listenConnection = dataSource.CreateConnection(); + await listenConnection.OpenAsync(_cancellationTokenSource.Token); + + listenConnection.Notification += (_, _) => + { + NotifyNewEvents(); + }; + + await using var listen = new NpgsqlCommand(@"LISTEN new_event", listenConnection); + await listen.ExecuteNonQueryAsync(_cancellationTokenSource.Token); + + while (!_cancellationTokenSource.Token.IsCancellationRequested) + { + await listenConnection.WaitAsync(_cancellationTokenSource.Token); + } + + await using var unlisten = new NpgsqlCommand(@"UNLISTEN new_event", listenConnection); + await unlisten.ExecuteNonQueryAsync(new CancellationToken()); + } + + private async void NotifyNewEvents() + { + await Task.Run(() => + { + _newEvents?.Invoke(this, EventArgs.Empty); + }); + } +} diff --git a/src/Fluss.PostgreSQL/ServiceCollectionExtensions.cs b/src/Fluss.PostgreSQL/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..81364c4 --- /dev/null +++ b/src/Fluss.PostgreSQL/ServiceCollectionExtensions.cs @@ -0,0 +1,149 @@ +using System.Reflection; +using FluentMigrator.Runner; +using Fluss.Core; +using Fluss.Events; +using Fluss.Upcasting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Fluss.PostgreSQL; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddPostgresEventSourcingRepository(this IServiceCollection services, + string connectionString, Assembly? upcasterSourceAssembly = null) + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (upcasterSourceAssembly is not null) + { + services + .AddUpcasters(upcasterSourceAssembly) + .AddHostedService(); + } + + return services + .AddScoped() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddPostgres() + .WithGlobalConnectionString(connectionString) + .ScanIn(typeof(Fluss.PostgreSQL.PostgreSQLEventRepository).Assembly).For.Migrations()) + .AddLogging(lb => lb.AddFluentMigratorConsole()) + .AddSingleton(new PostgreSQLConfig(connectionString)) + .AddSingleton() + .AddHostedService(sp => sp.GetRequiredService()); + } +} + +public class Migrator : BackgroundService +{ + private readonly ILogger _logger; + private readonly IMigrationRunner _migrationRunner; + private bool _didFinish; + + private readonly SemaphoreSlim _didFinishChanged = new(0, 1); + + public Migrator(IMigrationRunner migrationRunner, ILogger logger) + { + _migrationRunner = migrationRunner; + _logger = logger; + } + + public async Task WaitForFinish() + { + while (true) + { + if (_didFinish) + { + return; + } + await _didFinishChanged.WaitAsync(); + if (_didFinish) + { + _didFinishChanged.Release(); + return; + } + } + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + return Task.Run(() => + { + try + { + Migrate(); + } + catch (Exception e) + { + _logger.LogError(e, "Error while migrating"); + } + }, stoppingToken); + } + + public void Migrate() + { + //_migrationRunner.ListMigrations(); + try + { + _migrationRunner.MigrateUp(); + } + catch + { + Environment.Exit(-1); + } + + _didFinish = true; + _didFinishChanged.Release(); + } +} + +public class Upcaster : BackgroundService +{ + private readonly EventUpcasterService _upcasterService; + private readonly Migrator _migrator; + private readonly ILogger _logger; + + public Upcaster(EventUpcasterService upcasterService, Migrator migrator, ILogger logger) + { + _upcasterService = upcasterService; + _migrator = migrator; + _logger = logger; + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + return Task.Run(async () => + { + _logger.LogInformation("Waiting for migration to finish"); + await _migrator.WaitForFinish(); + _logger.LogInformation("Migration finished, starting event upcasting"); + + try + { + await _upcasterService.Run(); + _logger.LogInformation("Event upcasting finished"); + } + catch (Exception e) + { + _logger.LogError(e, "Error while upcasting"); + throw; + } + }, stoppingToken); + } +} + +public class PostgreSQLConfig +{ + public PostgreSQLConfig(string connectionString) + { + ConnectionString = connectionString; + } + + public string ConnectionString { get; } +} diff --git a/src/Fluss.Testing/AggregateTestBed.cs b/src/Fluss.Testing/AggregateTestBed.cs new file mode 100644 index 0000000..d594037 --- /dev/null +++ b/src/Fluss.Testing/AggregateTestBed.cs @@ -0,0 +1,104 @@ +using System.Reflection; +using Fluss.Aggregates; +using Fluss.Authentication; +using Fluss.Core.Validation; +using Fluss.Events; +using Fluss.Extensions; +using Moq; +using Xunit; + +namespace Fluss.Testing; + +public class AggregateTestBed : EventTestBed where TAggregate : AggregateRoot, new() +{ + private readonly UnitOfWork.UnitOfWork _unitOfWork; + private readonly IList _ignoredTypes = new List(); + + public AggregateTestBed() + { + var validator = new Mock(); + validator.Setup(v => v.ValidateEvent(It.IsAny())) + .Returns(_ => Task.CompletedTask); + validator.Setup(v => v.ValidateAggregate(It.IsAny(), It.IsAny())) + .Returns((_, _) => Task.CompletedTask); + + _unitOfWork = new UnitOfWork.UnitOfWork(EventRepository, EventListenerFactory, new[] { new AllowAllPolicy() }, + new UserIdProvider(_ => Guid.Empty, null!), validator.Object); + } + + public AggregateTestBed Calling(Func action) + { + action(_unitOfWork).GetAwaiter().GetResult(); + return this; + } + + public AggregateTestBed Calling(TKey key, Func action) + { + var aggregate = _unitOfWork.GetAggregate(key).GetResult(); + action(aggregate).GetAwaiter().GetResult(); + return this; + } + + public AggregateTestBed Ignoring() + { + _ignoredTypes.Add(typeof(TIgnoreType)); + + return this; + } + + public void ResultsIn(params Event[] expectedEvents) + { + var publishedEvents = _unitOfWork.PublishedEventEnvelopes.Select(ee => ee.Event).ToArray(); + + if (expectedEvents.Length == publishedEvents.Length) + { + for (int i = 0; i < expectedEvents.Length; i++) + { + expectedEvents[i] = GetEventRespectingIgnoredTypes(expectedEvents[i], publishedEvents[i]); + } + } + + Assert.Equal(expectedEvents, publishedEvents); + } + + private Event GetEventRespectingIgnoredTypes(Event expected, Event published) + { + if (expected.GetType() != published.GetType()) + { + return expected; + } + + var cloneMethod = expected.GetType().GetMethod("$"); + var exp = (Event)cloneMethod!.Invoke(expected, Array.Empty())!; + foreach (var fieldInfo in expected.GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (_ignoredTypes.Contains(fieldInfo.FieldType)) + { + fieldInfo.SetValue(exp, fieldInfo.GetValue(published)); + } + } + + return exp; + + } + + public override AggregateTestBed WithEvents(params Event[] events) + { + base.WithEvents(events); + return this; + } + + public override AggregateTestBed WithEventEnvelopes(params EventEnvelope[] eventEnvelopes) + { + base.WithEventEnvelopes(eventEnvelopes); + return this; + } + + private class AllowAllPolicy : Policy + { + public ValueTask AuthenticateEvent(EventEnvelope envelope, IAuthContext authContext) + { + return ValueTask.FromResult(true); + } + } +} diff --git a/src/Fluss.Testing/CanaryEvent.cs b/src/Fluss.Testing/CanaryEvent.cs new file mode 100644 index 0000000..bcdf8db --- /dev/null +++ b/src/Fluss.Testing/CanaryEvent.cs @@ -0,0 +1,9 @@ +using Fluss.Events; + +namespace Fluss.Testing; + +// This event should not be allowed by anything +public class CanaryEvent : Event +{ + +} diff --git a/src/Fluss.Testing/EventRepositoryTestBase.cs b/src/Fluss.Testing/EventRepositoryTestBase.cs new file mode 100644 index 0000000..bcd5089 --- /dev/null +++ b/src/Fluss.Testing/EventRepositoryTestBase.cs @@ -0,0 +1,189 @@ +using Fluss.Events; +using Fluss.Exceptions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Fluss.Testing; + +public abstract class EventRepositoryTestBase where T : IBaseEventRepository +{ + private static DateTimeOffset RemoveTicks(DateTimeOffset d) + { + return d.AddTicks(-(d.Ticks % TimeSpan.TicksPerSecond)); + } + + protected abstract T Repository { get; set; } + + [Fact] + public async Task ReturnRightLatestVersion() + { + Assert.Equal(-1, await Repository.GetLatestVersion()); + await Repository.Publish(GetMockEnvelopes(0, 0)); + Assert.Equal(0, await Repository.GetLatestVersion()); + } + + [Fact] + public async Task ReturnsPublishedEvents() + { + var envelopes = GetMockEnvelopes(0, 5).ToList(); + await Repository.Publish(envelopes); + + var repositoryEvents = await Repository.GetEvents(-1, 5).ToFlatEventList(); + Assert.Equal(envelopes, repositoryEvents, new AtTickIgnoringEnvelopeCompare()); + } + + [Fact] + public async Task ReturnsMultiplePublishedEvents() + { + var envelopes = GetMockEnvelopes(0, 1).ToList(); + await Repository.Publish(envelopes.Take(1)); + await Repository.Publish(envelopes.Skip(1)); + + var gottenEnvelopes = await Repository.GetEvents(-1, 1).ToFlatEventList(); + + Assert.Equal(envelopes, gottenEnvelopes, new AtTickIgnoringEnvelopeCompare()); + } + + [Fact] + public async Task ReturnsPartOfMultiplePublishedEvents() + { + var envelopes = GetMockEnvelopes(0, 2).ToList(); + await Repository.Publish(envelopes); + + var gottenEnvelopes = await Repository.GetEvents(0, 1).ToFlatEventList(); + Assert.Equal(envelopes.Skip(1).Take(1), gottenEnvelopes, new AtTickIgnoringEnvelopeCompare()); + } + + [Fact] + public async Task NotifiesOnNewEvent() + { + var didNotify = false; + + Repository.NewEvents += (_, _) => + { + didNotify = true; + }; + + await Repository.Publish(GetMockEnvelopes(0, 1)); + await Task.Delay(10); + + Assert.True(didNotify); + } + + [Fact] + public async Task ReturnsRawEvents() + { + var envelopes = GetMockEnvelopes(0, 2).ToList(); + await Repository.Publish(envelopes); + + var serializer = new JsonSerializer { TypeNameHandling = TypeNameHandling.All, TypeNameAssemblyFormatHandling = TypeNameAssemblyFormatHandling.Full }; + + var rawEnvelopes = (await Repository.GetRawEvents()).ToList(); + Assert.Equal(envelopes.Count, rawEnvelopes.Count); + for (int i = 0; i < envelopes.Count; i++) + { + var envelope = envelopes[i]; + var rawEnvelope = rawEnvelopes[i]; + + Assert.Equal(envelope.By, rawEnvelope.By); + Assert.Equal(envelope.Version, rawEnvelope.Version); + Assert.Equal(RemoveTicks(envelope.At), RemoveTicks(rawEnvelope.At)); + Assert.Equal(JObject.FromObject(envelope.Event, serializer), rawEnvelope.RawEvent); + } + } + + [Fact] + public async Task ReplacesEvents() + { + var envelopes = GetMockEnvelopes(0, 2).ToList(); + await Repository.Publish(envelopes); + + var replacements = GetMockEnvelopes(0, 2).Select(e => new RawEventEnvelope + { + At = e.At, + Version = e.Version + 1, + By = e.By, + RawEvent = e.Event.ToJObject() + }); + + await Repository.ReplaceEvent(1, replacements); + + var latestVersion = await Repository.GetLatestVersion(); + Assert.Equal(4, latestVersion); + + var repoEvents = await Repository.GetEvents(-1, latestVersion).ToFlatEventList(); + + for (var i = 0; i <= latestVersion; i++) + { + Assert.Equal(i, repoEvents[i].Version); + } + } + + [Fact] + public async Task DeletesEvents() + { + var envelopes = GetMockEnvelopes(0, 2).ToList(); + await Repository.Publish(envelopes); + + await Repository.ReplaceEvent(1, Enumerable.Empty()); + + var latestVersion = await Repository.GetLatestVersion(); + Assert.Equal(1, latestVersion); + + var repoEvents = await Repository.GetEvents(-1, latestVersion).ToFlatEventList(); + + for (var i = 0; i <= latestVersion; i++) + { + Assert.Equal(i, repoEvents[i].Version); + } + } + + [Fact] + public async Task SignalsRetryOnOutOfOrderEvents() + { + var envelopes = GetMockEnvelopes(0, 2).ToList(); + var envelopes2 = GetMockEnvelopes(0, 2).ToList(); + await Repository.Publish(envelopes); + + await Assert.ThrowsAsync(async () => + await Repository.Publish(envelopes2)); + } + + private IEnumerable GetMockEnvelopes(int from, int to) + { + return Enumerable.Range(from, to - from + 1).Select(version => + new EventEnvelope { At = DateTimeOffset.UtcNow, By = null, Version = version, Event = new MockEvent() }) + .ToList(); + } + + private record MockEvent : Event; + + public class AtTickIgnoringEnvelopeCompare : EqualityComparer + { + public override bool Equals(EventEnvelope? a, EventEnvelope? b) + { + if (a is null && b is null) + { + return true; + } + + if (a is null || b is null) + { + return false; + } + + return EnvelopeWithAtTickRemoved(a) == EnvelopeWithAtTickRemoved(b); + } + + public override int GetHashCode(EventEnvelope envelope) + { + return EnvelopeWithAtTickRemoved(envelope).GetHashCode(); + } + + private EventEnvelope EnvelopeWithAtTickRemoved(EventEnvelope envelope) + { + return envelope with { At = RemoveTicks(envelope.At) }; + } + } +} diff --git a/src/Fluss.Testing/EventTestBed.cs b/src/Fluss.Testing/EventTestBed.cs new file mode 100644 index 0000000..9fc335b --- /dev/null +++ b/src/Fluss.Testing/EventTestBed.cs @@ -0,0 +1,36 @@ +using Fluss.Events; +using Fluss.Extensions; + +namespace Fluss.Testing; + +public abstract class EventTestBed +{ + protected readonly InMemoryEventRepository EventRepository; + protected readonly EventListenerFactory EventListenerFactory; + + protected EventTestBed() + { + EventRepository = new InMemoryEventRepository(); + EventListenerFactory = new EventListenerFactory(EventRepository); + } + + public virtual EventTestBed WithEvents(params Event[] events) + { + var startingVersion = EventRepository.GetLatestVersion().GetAwaiter().GetResult(); + EventRepository.Publish(events.Select(@event => new EventEnvelope + { + Version = ++startingVersion, + At = DateTimeOffset.Now, + By = null, + Event = @event + })).GetAwaiter().GetResult(); + + return this; + } + + public virtual EventTestBed WithEventEnvelopes(params EventEnvelope[] eventEnvelopes) + { + EventRepository.Publish(eventEnvelopes).GetResult(); + return this; + } +} diff --git a/src/Fluss.Testing/Fluss.Testing.csproj b/src/Fluss.Testing/Fluss.Testing.csproj new file mode 100644 index 0000000..bc6b237 --- /dev/null +++ b/src/Fluss.Testing/Fluss.Testing.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Fluss.Testing/PolicyTestBed.cs b/src/Fluss.Testing/PolicyTestBed.cs new file mode 100644 index 0000000..c30bb2c --- /dev/null +++ b/src/Fluss.Testing/PolicyTestBed.cs @@ -0,0 +1,115 @@ +using Fluss.Authentication; +using Fluss.Events; +using Fluss.Extensions; +using Fluss.ReadModel; +using Xunit; + +namespace Fluss.Testing; + +public class PolicyTestBed : EventTestBed where TPolicy : Policy, new() +{ + private Guid _userId = Guid.Empty; + private readonly TPolicy _policy; + private readonly AuthContextMock _authContext; + + public PolicyTestBed() + { + _policy = new TPolicy(); + _authContext = new AuthContextMock(this); + } + + public PolicyTestBed WithUser(Guid userId) + { + _userId = userId; + return this; + } + + public PolicyTestBed Allows(params Event[] events) + { + foreach (var @event in events) + { + Assert.True(_policy.AuthenticateEvent(GetEnvelope(@event), _authContext).GetResult(), $"Event should be allowed {@event}"); + } + + AssertPolicyDoesNoAllowCanary(); + + return this; + } + + public PolicyTestBed Refuses(params Event[] events) + { + foreach (var @event in events) + { + Assert.False(_policy.AuthenticateEvent(GetEnvelope(@event), _authContext).GetResult(), $"Event should not be allowed {@event}"); + } + + AssertPolicyDoesNoAllowCanary(); + + return this; + } + + private void AssertPolicyDoesNoAllowCanary() + { + Assert.False(_policy.AuthenticateEvent(new EventEnvelope + { + At = DateTimeOffset.Now, + Event = new CanaryEvent(), + Version = EventRepository.GetLatestVersion().GetResult() + 1 + }, _authContext).GetResult(), "Policy should not allow any event"); + } + + public override PolicyTestBed WithEvents(params Event[] events) + { + base.WithEvents(events); + return this; + } + + public override PolicyTestBed WithEventEnvelopes(params EventEnvelope[] eventEnvelopes) + { + base.WithEventEnvelopes(eventEnvelopes); + return this; + } + + private EventEnvelope GetEnvelope(Event @event) + { + return new EventEnvelope + { + At = DateTimeOffset.Now, + By = _userId, + Event = @event, + Version = EventRepository.GetLatestVersion().GetResult() + }; + } + + private class AuthContextMock : IAuthContext + { + private readonly PolicyTestBed _policyTestBed; + + public AuthContextMock(PolicyTestBed policyTestBed) + { + _policyTestBed = policyTestBed; + } + + public async ValueTask CacheAndGet(string key, Func> func) + { + return await func(); + } + + public async ValueTask GetReadModel() where TReadModel : EventListener, IRootEventListener, IReadModel, new() + { + return await _policyTestBed.EventListenerFactory.UpdateTo(new TReadModel(), await _policyTestBed.EventRepository.GetLatestVersion()); + } + + public async ValueTask GetReadModel(TKey key) where TReadModel : EventListener, IEventListenerWithKey, IReadModel, new() + { + return await _policyTestBed.EventListenerFactory.UpdateTo(new TReadModel { Id = key }, await _policyTestBed.EventRepository.GetLatestVersion()); + } + + public async ValueTask> GetMultipleReadModels(IEnumerable keys) where TReadModel : EventListener, IReadModel, IEventListenerWithKey, new() where TKey : notnull + { + return await Task.WhenAll(keys.Select(async k => await GetReadModel(k))); + } + + public Guid UserId => _policyTestBed._userId; + } +} diff --git a/src/Fluss.Testing/ReadModelTestBed.cs b/src/Fluss.Testing/ReadModelTestBed.cs new file mode 100644 index 0000000..4d4aa60 --- /dev/null +++ b/src/Fluss.Testing/ReadModelTestBed.cs @@ -0,0 +1,56 @@ +using Fluss.Events; +using Fluss.Extensions; +using Fluss.ReadModel; +using Xunit; + +namespace Fluss.Testing; + +public class ReadModelTestBed : EventTestBed +{ + public ReadModelTestBed ResultsIn(TReadModel readModel) where TReadModel : RootReadModel, new() + { + var eventSourced = EventListenerFactory + .UpdateTo(new TReadModel(), EventRepository.GetLatestVersion().GetResult()).GetResult(); + + Assert.Equal(readModel with { Tag = eventSourced.Tag }, eventSourced); + + AssertReadModelDoesNotReactToCanary(eventSourced); + + return this; + } + + public ReadModelTestBed ResultsIn(TReadModel readModel) + where TReadModel : ReadModelWithKey, new() + { + var eventSourced = EventListenerFactory + .UpdateTo(new TReadModel { Id = readModel.Id }, EventRepository.GetLatestVersion().GetResult()).GetResult(); + + Assert.Equal(readModel with { Tag = eventSourced.Tag }, eventSourced); + + AssertReadModelDoesNotReactToCanary(eventSourced); + + return this; + } + + private void AssertReadModelDoesNotReactToCanary(EventListener readModel) + { + Assert.True(readModel == readModel.WhenInt(new EventEnvelope + { + At = DateTimeOffset.Now, + Event = new CanaryEvent(), + Version = EventRepository.GetLatestVersion().GetResult() + 1 + }), "Read model should not react to arbitrary events"); + } + + public override ReadModelTestBed WithEvents(params Event[] events) + { + base.WithEvents(events); + return this; + } + + public override ReadModelTestBed WithEventEnvelopes(params EventEnvelope[] eventEnvelopes) + { + base.WithEventEnvelopes(eventEnvelopes); + return this; + } +} diff --git a/src/Fluss.UnitTest/Core/Authentication/AuthContextTest.cs b/src/Fluss.UnitTest/Core/Authentication/AuthContextTest.cs new file mode 100644 index 0000000..a8ebcf8 --- /dev/null +++ b/src/Fluss.UnitTest/Core/Authentication/AuthContextTest.cs @@ -0,0 +1,80 @@ +using Fluss.Authentication; +using Fluss.Events; +using Fluss.ReadModel; +using Fluss.UnitOfWork; +using Moq; + +namespace Fluss.UnitTest.Core.Authentication; + +public class AuthContextTest +{ + private readonly Mock _unitOfWork; + private readonly Guid _userId; + private readonly AuthContext _authContext; + + public AuthContextTest() + { + _unitOfWork = new Mock(); + _userId = Guid.NewGuid(); + _authContext = new AuthContext(_unitOfWork.Object, _userId); + } + + [Fact] + public async Task CanCacheValues() + { + var result1 = await _authContext.CacheAndGet("test", () => Task.FromResult("foo")); + var result2 = await _authContext.CacheAndGet("test", () => Task.FromResult("bar")); + + Assert.Equal("foo", result1); + Assert.Equal("foo", result2); + } + + [Fact] + public async Task ForwardsGetReadModel() + { + var testReadModel = new TestReadModel(); + _unitOfWork + .Setup(uow => uow.UnsafeGetReadModelWithoutAuthorization(null)) + .Returns(ValueTask.FromResult(testReadModel)); + + Assert.Equal(testReadModel, await _authContext.GetReadModel()); + } + + [Fact] + public async Task ForwardsGetReadModelWithKey() + { + var testReadModel = new TestReadModelWithKey { Id = 0 }; + _unitOfWork + .Setup(uow => uow.UnsafeGetReadModelWithoutAuthorization(0, null)) + .Returns(ValueTask.FromResult(testReadModel)); + + Assert.Equal(testReadModel, await _authContext.GetReadModel(0)); + } + + [Fact] + public async Task ForwardsGetMultipleReadModels() + { + var testReadModel = new TestReadModelWithKey { Id = 0 }; + _unitOfWork + .Setup(uow => uow.UnsafeGetMultipleReadModelsWithoutAuthorization(new[] { 0, 1 }, null)) + .Returns(ValueTask.FromResult((IReadOnlyList)new List { testReadModel }.AsReadOnly())); + + Assert.Equal(new List { testReadModel }, await _authContext.GetMultipleReadModels(new[] { 0, 1 })); + } + + public record TestReadModel : RootReadModel + { + protected override EventListener When(EventEnvelope envelope) + { + throw new NotImplementedException(); + } + } + + public record TestReadModelWithKey : ReadModelWithKey + { + protected override EventListener When(EventEnvelope envelope) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Fluss.UnitTest/Core/Events/EventListenerFactoryTest.cs b/src/Fluss.UnitTest/Core/Events/EventListenerFactoryTest.cs new file mode 100644 index 0000000..31b2d71 --- /dev/null +++ b/src/Fluss.UnitTest/Core/Events/EventListenerFactoryTest.cs @@ -0,0 +1,64 @@ +using Fluss.Events; +using Fluss.ReadModel; + +namespace Fluss.UnitTest.Core.Events; + +public class EventListenerFactoryTest +{ + private readonly EventListenerFactory _eventListenerFactory; + private readonly InMemoryEventRepository _eventRepository; + + public EventListenerFactoryTest() + { + _eventRepository = new InMemoryEventRepository(); + _eventListenerFactory = new EventListenerFactory(_eventRepository); + + _eventRepository.Publish(new[] { + new EventEnvelope { Event = new TestEvent(1), Version = 0 }, + new EventEnvelope { Event = new TestEvent(2), Version = 1 }, + new EventEnvelope { Event = new TestEvent(1), Version = 2 }, + }); + } + + [Fact] + public async Task CanGetRootReadModel() + { + var rootReadModel = await _eventListenerFactory.UpdateTo(new TestRootReadModel(), 2); + Assert.Equal(3, rootReadModel.GotEvents); + } + + [Fact] + public async Task CanGetReadModel() + { + var readModel = await _eventListenerFactory.UpdateTo(new TestReadModel { Id = 1 }, 2); + Assert.Equal(2, readModel.GotEvents); + } + + private record TestRootReadModel : RootReadModel + { + public int GotEvents { get; private set; } + protected override TestRootReadModel When(EventEnvelope envelope) + { + return envelope.Event switch + { + TestEvent => this with { GotEvents = GotEvents + 1 }, + _ => this + }; + } + } + + private record TestReadModel : ReadModelWithKey + { + public int GotEvents { get; private set; } + protected override TestReadModel When(EventEnvelope envelope) + { + return envelope.Event switch + { + TestEvent testEvent when testEvent.Id == Id => this with { GotEvents = GotEvents + 1 }, + _ => this + }; + } + } + + private record TestEvent(int Id) : Event; +} diff --git a/src/Fluss.UnitTest/Core/Events/InMemoryCacheTest.cs b/src/Fluss.UnitTest/Core/Events/InMemoryCacheTest.cs new file mode 100644 index 0000000..a5772fd --- /dev/null +++ b/src/Fluss.UnitTest/Core/Events/InMemoryCacheTest.cs @@ -0,0 +1,109 @@ +using System.Collections.ObjectModel; +using Fluss.Events; +using Moq; + +namespace Fluss.UnitTest.Core.Events; + +public class InMemoryCacheTest +{ + private readonly Mock _baseRepository; + private readonly InMemoryEventCache _cache; + + public InMemoryCacheTest() + { + _cache = new InMemoryEventCache(20); + _baseRepository = new Mock(); + + _cache.Next = _baseRepository.Object; + } + + [Fact] + public async Task CallsBaseRepository() + { + _baseRepository.Setup(repository => repository.GetEvents(-1, 9)) + .Returns(ValueTask.FromResult(GetMockEnvelopes(0, 9))); + + var events = (await _cache.GetEvents(-1, 9)).ToFlatEventList(); + + _baseRepository.Verify(repository => repository.GetEvents(-1, 9), Times.Once); + + Assert.Equal(10, events.Count); + + for (var i = 0; i < events.Count; i++) + { + Assert.Equal(i, events[i].Version); + } + } + + [Fact] + public async Task DoesNotCallBaseRepositoryTwice() + { + _baseRepository.Setup(repository => repository.GetEvents(-1, 4)) + .Returns(ValueTask.FromResult(GetMockEnvelopes(0, 4))); + + await _cache.GetEvents(-1, 4); + await _cache.GetEvents(-1, 4); + + _baseRepository.Verify(repository => repository.GetEvents(-1, 4), Times.Once); + } + + [Fact] + public async Task DoesNotCallForPublishedEvents() + { + var events = GetMockEnvelopes(0, 4).ToFlatEventList(); + + await _cache.Publish(events); + await _cache.GetEvents(-1, 4); + + _baseRepository.Verify(repository => repository.Publish(events), Times.Once); + _baseRepository.Verify(repository => repository.GetEvents(-1, 4), Times.Never); + } + + [Fact] + public async Task CanRequestSecondCacheItem() + { + _baseRepository.Setup(repository => repository.GetEvents(-1, 39)) + .Returns(ValueTask.FromResult(GetMockEnvelopes(0, 39))); + + await _cache.GetEvents(-1, 39); + var events = (await _cache.GetEvents(19, 39)).ToFlatEventList(); + + _baseRepository.Verify(repository => repository.GetEvents(-1, 39), Times.Once); + + Assert.Equal(20, events.Count); + + for (var i = 0; i < events.Count; i++) + { + Assert.Equal(i + 20, events[i].Version); + } + } + + + [Fact] + public async Task CanRequestAcrossMultipleCacheItems() + { + _baseRepository.Setup(repository => repository.GetEvents(-1, 59)) + .Returns(ValueTask.FromResult(GetMockEnvelopes(0, 59))); + + await _cache.GetEvents(-1, 59); + var events = (await _cache.GetEvents(15, 45)).ToFlatEventList(); + + _baseRepository.Verify(repository => repository.GetEvents(-1, 59), Times.Once); + + Assert.Equal(30, events.Count); + + for (var i = 0; i < events.Count; i++) + { + Assert.Equal(i + 16, events[i].Version); + } + } + + private ReadOnlyCollection> GetMockEnvelopes(int from, int to) + { + return Enumerable.Range(from, to - from + 1).Select(version => + new EventEnvelope { At = DateTimeOffset.Now, By = null, Version = version, Event = new MockEvent() }) + .ToList().ToPagedMemory(); + } + + private class MockEvent : Event { } +} diff --git a/src/Fluss.UnitTest/Core/Events/InMemoryEventRepositoryTest.cs b/src/Fluss.UnitTest/Core/Events/InMemoryEventRepositoryTest.cs new file mode 100644 index 0000000..7d7e91b --- /dev/null +++ b/src/Fluss.UnitTest/Core/Events/InMemoryEventRepositoryTest.cs @@ -0,0 +1,16 @@ +using Fluss.Events; +using Fluss.Testing; + +namespace Fluss.UnitTest.Core.Events; + +public class InMemoryEventRepositoryTest : EventRepositoryTestBase +{ + protected sealed override InMemoryEventRepository Repository { get; set; } = new(); + + [Fact] + public async Task ThrowsOnWrongGetEventsUsage() + { + await Assert.ThrowsAsync(async () => + await Repository.GetEvents(2, 0)); + } +} diff --git a/src/Fluss.UnitTest/Core/Events/InMemoryListenerCacheTest.cs b/src/Fluss.UnitTest/Core/Events/InMemoryListenerCacheTest.cs new file mode 100644 index 0000000..5a0637f --- /dev/null +++ b/src/Fluss.UnitTest/Core/Events/InMemoryListenerCacheTest.cs @@ -0,0 +1,169 @@ +using Fluss.Events; +using Moq; + +namespace Fluss.UnitTest.Core.Events; + +public class InMemoryListenerCacheTest +{ + private readonly Mock _baseEventListenerFactory; + private readonly InMemoryEventListenerCache _listenerCache; + + public InMemoryListenerCacheTest() + { + _baseEventListenerFactory = new Mock(); + _listenerCache = new InMemoryEventListenerCache + { + Next = _baseEventListenerFactory.Object + }; + } + + [Fact] + public async Task PassesUpdatesToNext() + { + // ReSharper disable once ReturnValueOfPureMethodIsNotUsed + _baseEventListenerFactory.Setup(f => f.UpdateTo(It.IsAny(), 100)) + .Returns(ValueTask.FromResult(new TestEventListener())); + + _ = await _listenerCache.UpdateTo(new TestEventListener(), 100); + + _baseEventListenerFactory.Verify( + f => f.UpdateTo( + It.IsAny(), + 100 + ), + Times.Once + ); + } + + [Fact] + public async Task ReturnsCachedEventListener() + { + // ReSharper disable once ReturnValueOfPureMethodIsNotUsed + _baseEventListenerFactory.Setup(f => f.UpdateTo(It.IsAny(), 100)) + .Returns(ValueTask.FromResult(new TestEventListener { Tag = new EventListenerVersionTag(100) })); + + _ = await _listenerCache.UpdateTo(new TestEventListener(), 100); + _ = await _listenerCache.UpdateTo(new TestEventListener(), 100); + + _baseEventListenerFactory.Verify( + f => f.UpdateTo( + It.IsAny(), + 100 + ), + Times.Once + ); + } + + [Fact] + public async Task ReturnsCachedKeyedEventListener() + { + // ReSharper disable once ReturnValueOfPureMethodIsNotUsed + _baseEventListenerFactory.Setup(f => f.UpdateTo(It.IsAny(), 100)) + .Returns(ValueTask.FromResult(new KeyedTestEventListener { Id = 1, Tag = new EventListenerVersionTag(100) })); + + await _listenerCache.UpdateTo(new KeyedTestEventListener { Id = 1 }, 100); + await _listenerCache.UpdateTo(new KeyedTestEventListener { Id = 1 }, 100); + + _baseEventListenerFactory.Verify( + f => f.UpdateTo( + It.IsAny(), + 100 + ), + Times.Once + ); + } + + [Fact] + public async Task ForwardsIfCacheContainsNewer() + { + // ReSharper disable once ReturnValueOfPureMethodIsNotUsed + _baseEventListenerFactory.Setup(f => f.UpdateTo(It.IsAny(), 100)) + .Returns(ValueTask.FromResult(new TestEventListener { Tag = new EventListenerVersionTag(100) })); + _baseEventListenerFactory.Setup(f => f.UpdateTo(It.IsAny(), 90)) + .Returns(ValueTask.FromResult(new TestEventListener { Tag = new EventListenerVersionTag(90) })); + + await _listenerCache.UpdateTo(new TestEventListener(), 100); + await _listenerCache.UpdateTo(new TestEventListener(), 90); + + _baseEventListenerFactory.Verify( + f => f.UpdateTo( + It.IsAny(), + 100 + ), + Times.Once + ); + _baseEventListenerFactory.Verify( + f => f.UpdateTo( + It.IsAny(), + 90 + ), + Times.Once + ); + } + + [Fact] + public async Task UpdatesStoreWithNewerVersion() + { + // ReSharper disable once ReturnValueOfPureMethodIsNotUsed + var testEventListener = new TestEventListener(); + var otherTestEventList = new TestEventListener(); + + _baseEventListenerFactory.Setup(f => f.UpdateTo(It.IsAny(), 100)) + .Returns(ValueTask.FromResult(new TestEventListener { Tag = new EventListenerVersionTag(100) })); + _baseEventListenerFactory.Setup(f => f.UpdateTo(It.IsAny(), 110)) + .Returns(ValueTask.FromResult(new TestEventListener { Tag = new EventListenerVersionTag(110) })); + + await _listenerCache.UpdateTo(testEventListener, 100); + + await _listenerCache.UpdateTo(otherTestEventList, 110); + await _listenerCache.UpdateTo(otherTestEventList, 110); + + _baseEventListenerFactory.Verify( + f => f.UpdateTo( + It.IsAny(), + 110 + ), + Times.Once + ); + } + + [Fact] + public async Task ForwardsAgainIfCleaned() + { + // ReSharper disable once ReturnValueOfPureMethodIsNotUsed + + _baseEventListenerFactory.Setup(f => f.UpdateTo(It.IsAny(), 100)) + .Returns(ValueTask.FromResult(new TestEventListener { Tag = new EventListenerVersionTag(100) })); + + await _listenerCache.UpdateTo(new TestEventListener(), 100); + _listenerCache.Clean(); + await _listenerCache.UpdateTo(new TestEventListener(), 100); + + + _baseEventListenerFactory.Verify( + f => f.UpdateTo( + It.IsAny(), + 100 + ), + Times.Exactly(2) + ); + } + + private record TestEventListener : EventListener + { + protected override TestEventListener When(EventEnvelope envelope) + { + return this; + } + } + + private record KeyedTestEventListener : EventListener, IEventListenerWithKey + { + public int Id { get; init; } + + protected override KeyedTestEventListener When(EventEnvelope envelope) + { + return this; + } + } +} diff --git a/src/Fluss.UnitTest/Core/Extensions/ValueTaskTest.cs b/src/Fluss.UnitTest/Core/Extensions/ValueTaskTest.cs new file mode 100644 index 0000000..c8a0d9c --- /dev/null +++ b/src/Fluss.UnitTest/Core/Extensions/ValueTaskTest.cs @@ -0,0 +1,57 @@ +using Fluss.Extensions; + +namespace Fluss.UnitTest.Core.Extensions; + +public class ValueTaskTest +{ + [Fact] + public async void AnyReturnsFalse() + { + Assert.False(await new[] { False() }.AnyAsync()); + } + + [Fact] + public async void AnyReturnsTrue() + { + Assert.True(await new[] { False(), True() }.AnyAsync()); + } + + [Fact] + public async void AllReturnsFalse() + { + Assert.False(await new[] { False(), True() }.AllAsync()); + } + + [Fact] + public async void AllReturnsTrue() + { + Assert.True(await new[] { True(), True() }.AllAsync()); + } + + [Fact] + public void GetResultWorksForBoolean() + { + Assert.True(True().GetResult()); + } + + [Fact] + public void GetResultWorksForEmpty() + { + Empty().GetResult(); + } + + private ValueTask True() + { + return ValueTask.FromResult(true); + } + + private ValueTask False() + { + return ValueTask.FromResult(false); + } + + private ValueTask Empty() + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Fluss.UnitTest/Core/TransientEvents/TransientEventAwareEventListenerFactoryTest.cs b/src/Fluss.UnitTest/Core/TransientEvents/TransientEventAwareEventListenerFactoryTest.cs new file mode 100644 index 0000000..838af48 --- /dev/null +++ b/src/Fluss.UnitTest/Core/TransientEvents/TransientEventAwareEventListenerFactoryTest.cs @@ -0,0 +1,53 @@ +using Fluss.Events; +using Fluss.Events.TransientEvents; +using Fluss.ReadModel; + +namespace Fluss.UnitTest.Core.TransientEvents; + +public class TransientEventAwareEventListenerFactoryTest +{ + private readonly TransientEventAwareEventListenerFactory _transientFactory; + private readonly TransientEventAwareEventRepository _transientRepository; + + public TransientEventAwareEventListenerFactoryTest() + { + _transientRepository = new TransientEventAwareEventRepository + { + Next = new InMemoryEventRepository() + }; + + _transientFactory = + new TransientEventAwareEventListenerFactory(_transientRepository) + { + Next = new EventListenerFactory(_transientRepository), + }; + } + + [Fact] + public async Task AppliesTransientEvents() + { + var transientEventEnvelope = new TransientEventEnvelope + { + Event = new ExampleTransientEvent(), + Version = 0, + At = new DateTimeOffset(), + ExpiresAt = new DateTimeOffset().AddMinutes(1) + }; + + var readModel = new ExampleReadModel(); + await _transientRepository.Publish(new[] { transientEventEnvelope }); + + var updatedReadModel = await _transientFactory.UpdateTo(readModel, -1); + + Assert.Equal(0, updatedReadModel.Tag.LastSeenTransient); + } +} + +record ExampleReadModel : RootReadModel +{ + protected override EventListener When(EventEnvelope envelope) => this; +} + +class ExampleTransientEvent : TransientEvent +{ +} diff --git a/src/Fluss.UnitTest/Core/TransientEvents/TransientEventAwareEventRepositoryTest.cs b/src/Fluss.UnitTest/Core/TransientEvents/TransientEventAwareEventRepositoryTest.cs new file mode 100644 index 0000000..9becac0 --- /dev/null +++ b/src/Fluss.UnitTest/Core/TransientEvents/TransientEventAwareEventRepositoryTest.cs @@ -0,0 +1,100 @@ +using Fluss.Events; +using Fluss.Events.TransientEvents; +using Moq; + +namespace Fluss.UnitTest.Core.TransientEvents; + +public class TransientEventAwareEventRepositoryTest +{ + private readonly Mock _baseRepository; + private readonly TransientEventAwareEventRepository _transientRepository; + + public TransientEventAwareEventRepositoryTest() + { + _transientRepository = new TransientEventAwareEventRepository(); + _baseRepository = new Mock(); + + _transientRepository.Next = _baseRepository.Object; + } + + [Fact] + public async Task HidesTransientEventsFromRemainingPipeline() + { + var mockEvent = new MockEvent(); + var transientMockEvent = new TransientMockEvent(); + + var envelopes = Wrap(mockEvent, transientMockEvent); + + await _transientRepository.Publish(envelopes); + _baseRepository.Verify(repository => repository.Publish(new[] { envelopes.First() }), Times.Once); + + _baseRepository.Reset(); + + var reverseEnvelopes = Wrap(transientMockEvent, mockEvent); + await _transientRepository.Publish(reverseEnvelopes); + var expectedBasePublish = reverseEnvelopes.Last() with { Version = 0 }; + _baseRepository.Verify(repository => repository.Publish(new[] { expectedBasePublish }), Times.Once); + } + + [Fact] + public async Task DoesntCleanEventsBeforeExpiry() + { + var envelopes = Wrap(new TransientMockEvent(), new ExpiringTransientMockEvent()); + await _transientRepository.Publish(envelopes); + + var firstResult = _transientRepository.GetCurrentTransientEvents().ToFlatEventList(); + Assert.Equal(2, firstResult.Count); + + Thread.Sleep(300); + + var secondResult = _transientRepository.GetCurrentTransientEvents().ToFlatEventList(); + Assert.Single(secondResult); + + Thread.Sleep(150); + + var thirdResult = _transientRepository.GetCurrentTransientEvents().ToFlatEventList(); + Assert.Empty(thirdResult); + } + + private List Wrap(params Event[] events) + { + return events.Select((e, i) => + new EventEnvelope { At = DateTimeOffset.Now, By = null, Version = i, Event = e }) + .ToList(); + } + + private class MockEvent : Event { } + + private class TransientMockEvent : TransientEvent { } + + [ExpiresAfter(200)] + private class ExpiringTransientMockEvent : TransientEvent { } + + private class EventEnvelopeEqualityComparer : IEqualityComparer + { + public bool Equals(EventEnvelope? x, EventEnvelope? y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (ReferenceEquals(x, null)) + { + return false; + } + + if (ReferenceEquals(y, null)) + { + return false; + } + + return x.At == y.At && x.Event == y.Event && x.Version == y.Version; + } + + public int GetHashCode(EventEnvelope obj) + { + return obj.Event.GetHashCode(); + } + } +} diff --git a/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkAndAuthorizationTest.cs b/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkAndAuthorizationTest.cs new file mode 100644 index 0000000..9ed185c --- /dev/null +++ b/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkAndAuthorizationTest.cs @@ -0,0 +1,51 @@ +using Fluss.Authentication; +using Fluss.Events; +using Fluss.ReadModel; + +namespace Fluss.UnitTest.Core.UnitOfWork; + +public partial class UnitOfWorkTest +{ + [Fact] + public async Task DoesNotReuseCacheWhenNewEventIsAdded() + { + _policies.Add(new AllowReadAfterEventPolicy()); + + await Assert.ThrowsAsync(async () => + { + await _unitOfWork.GetReadModel(1); + }); + + await _unitOfWork.Publish(new AllowEvent()); + await _unitOfWork.GetReadModel(1); + } + + private record AllowEvent : Event; + + private record HasAllowEventReadModel : RootReadModel + { + public bool HasAllowEvent { get; init; } = false; + + protected override EventListener When(EventEnvelope envelope) + { + return envelope.Event switch + { + AllowEvent => this with { HasAllowEvent = true }, + _ => this + }; + } + }; + + private class AllowReadAfterEventPolicy : Policy + { + public ValueTask AuthenticateEvent(EventEnvelope envelope, IAuthContext authContext) + { + return ValueTask.FromResult(true); + } + + public async ValueTask AuthenticateReadModel(IReadModel readModel, IAuthContext authContext) + { + return (await authContext.GetReadModel()).HasAllowEvent; + } + } +} diff --git a/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkTest.cs b/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkTest.cs new file mode 100644 index 0000000..1e8c898 --- /dev/null +++ b/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkTest.cs @@ -0,0 +1,276 @@ +using Fluss.Aggregates; +using Fluss.Authentication; +using Fluss.Core.Validation; +using Fluss.Events; +using Fluss.ReadModel; +using Fluss.UnitOfWork; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Fluss.UnitTest.Core.UnitOfWork; + +public partial class UnitOfWorkTest +{ + private readonly InMemoryEventRepository _eventRepository; + private readonly EventListenerFactory _eventListenerFactory; + private readonly Guid _userId; + private readonly List _policies; + private readonly Fluss.UnitOfWork.UnitOfWork _unitOfWork; + private readonly UnitOfWorkFactory _unitOfWorkFactory; + + private readonly Mock _validator; + + public UnitOfWorkTest() + { + _eventRepository = new InMemoryEventRepository(); + _eventListenerFactory = new EventListenerFactory(_eventRepository); + _userId = Guid.NewGuid(); + _policies = new List(); + + _validator = new Mock(MockBehavior.Strict); + _validator.Setup(v => v.ValidateEvent(It.IsAny())) + .Returns(_ => Task.CompletedTask); + _validator.Setup(v => v.ValidateAggregate(It.IsAny(), It.IsAny())) + .Returns((_, _) => Task.CompletedTask); + + _unitOfWork = new Fluss.UnitOfWork.UnitOfWork( + _eventRepository, + _eventListenerFactory, + _policies, + new UserIdProvider(_ => _userId, null!), + _validator.Object + ); + + _eventRepository.Publish(new[] { + new EventEnvelope { Event = new TestEvent(1), Version = 0 }, + new EventEnvelope { Event = new TestEvent(2), Version = 1 }, + new EventEnvelope { Event = new TestEvent(1), Version = 2 }, + }); + + _unitOfWorkFactory = new UnitOfWorkFactory( + new ServiceCollection() + .AddScoped(_ => _unitOfWork) + .BuildServiceProvider()); + } + + [Fact] + public async Task CanGetConsistentVersion() + { + Assert.Equal(2, await _unitOfWork.ConsistentVersion()); + } + + [Fact] + public async Task CanGetAggregate() + { + var existingAggregate = await _unitOfWork.GetAggregate(1); + Assert.True(existingAggregate.Exists); + + var notExistingAggregate = await _unitOfWork.GetAggregate(100); + Assert.False(notExistingAggregate.Exists); + } + + [Fact] + public async Task CanPublish() + { + _policies.Add(new AllowAllPolicy()); + var notExistingAggregate = await _unitOfWork.GetAggregate(100); + await notExistingAggregate.Create(); + await _unitOfWork.CommitInternal(); + + var latestVersion = await _eventRepository.GetLatestVersion(); + var newEvent = await _eventRepository.GetEvents(latestVersion - 1, latestVersion).ToFlatEventList(); + Assert.Equal(new TestEvent(100), newEvent.First().Event); + } + + [Fact] + public async Task ThrowsWhenPublishNotAllowed() + { + await Assert.ThrowsAsync(async () => + { + var notExistingAggregate = await _unitOfWork.GetAggregate(100); + await notExistingAggregate.Create(); + await _unitOfWork.CommitInternal(); + }); + } + + [Fact] + public async Task CanGetAggregateTwice() + { + _policies.Add(new AllowAllPolicy()); + var notExistingAggregate = await _unitOfWork.GetAggregate(100); + await notExistingAggregate.Create(); + var existingAggregate = await _unitOfWork.GetAggregate(100); + Assert.True(existingAggregate.Exists); + } + + [Fact] + public async Task CanGetRootReadModel() + { + _policies.Add(new AllowAllPolicy()); + + var rootReadModel = await _unitOfWork.GetReadModel(); + Assert.Equal(3, rootReadModel.GotEvents); + } + + [Fact] + public async Task CanGetRootReadModelUnsafe() + { + var rootReadModel = await _unitOfWork.UnsafeGetReadModelWithoutAuthorization(); + Assert.Equal(3, rootReadModel.GotEvents); + } + + [Fact] + public async Task ThrowsWhenRootReadModelNotAuthorized() + { + await Assert.ThrowsAsync(async () => + { + await _unitOfWork.GetReadModel(); + }); + } + + [Fact] + public async Task CanGetReadModel() + { + _policies.Add(new AllowAllPolicy()); + + var readModel = await _unitOfWork.GetReadModel(1); + Assert.Equal(2, readModel.GotEvents); + } + + [Fact] + public async Task CanGetReadModelUnsafe() + { + var readModel = await _unitOfWork.UnsafeGetReadModelWithoutAuthorization(1); + Assert.Equal(2, readModel.GotEvents); + } + + [Fact] + public async Task ThrowsWhenReadModelNotAuthorized() + { + await Assert.ThrowsAsync(async () => + { + await _unitOfWork.GetReadModel(1); + }); + } + + [Fact] + public async Task CanGetMultipleReadModels() + { + _policies.Add(new AllowAllPolicy()); + + var readModels = await _unitOfWork.GetMultipleReadModels(new[] { 1, 2 }); + Assert.Equal(2, readModels[0].GotEvents); + Assert.Equal(1, readModels[1].GotEvents); + Assert.Equal(2, readModels.Count); + + Assert.Equal(2, _unitOfWork.ReadModels.Count); + Assert.Contains(_unitOfWork.ReadModels, rm => rm == readModels[0]); + Assert.Contains(_unitOfWork.ReadModels, rm => rm == readModels[1]); + } + + + [Fact] + public async Task ReturnsNothingWhenMultipleReadModelNotAuthorized() + { + var readModels = await _unitOfWork.GetMultipleReadModels(new[] { 1, 2 }); + Assert.Equal(0, readModels.Count(rm => rm != null)); + } + + [Fact] + public async Task CanGetMultipleReadModelsUnsafe() + { + var readModels = await _unitOfWork.UnsafeGetMultipleReadModelsWithoutAuthorization(new[] { 1, 2 }); + Assert.Equal(2, readModels[0].GotEvents); + Assert.Equal(1, readModels[1].GotEvents); + Assert.Equal(2, readModels.Count); + } + + [Fact] + public async Task CanCommitWithFactory() + { + _policies.Add(new AllowAllPolicy()); + + await _unitOfWorkFactory.Commit(async unitOfWork => + { + var aggregate = await unitOfWork.GetAggregate(100); + await aggregate.Create(); + }); + + Assert.Equal(3, await _eventRepository.GetLatestVersion()); + } + + [Fact] + public async Task CanCommitAndReturnValueWithFactory() + { + _policies.Add(new AllowAllPolicy()); + + var value = await _unitOfWorkFactory.Commit(async unitOfWork => + { + var aggregate = await unitOfWork.GetAggregate(100); + await aggregate.Create(); + + return 42; + }); + + Assert.Equal(3, await _eventRepository.GetLatestVersion()); + Assert.Equal(42, value); + } + + private record TestRootReadModel : RootReadModel + { + public int GotEvents { get; private init; } + protected override TestRootReadModel When(EventEnvelope envelope) + { + return envelope.Event switch + { + TestEvent => this with { GotEvents = GotEvents + 1 }, + _ => this + }; + } + } + + private record TestReadModel : ReadModelWithKey + { + public int GotEvents { get; private init; } + protected override TestReadModel When(EventEnvelope envelope) + { + return envelope.Event switch + { + TestEvent testEvent when testEvent.Id == Id => this with { GotEvents = GotEvents + 1 }, + _ => this + }; + } + } + + private record TestAggregate : AggregateRoot + { + public ValueTask Create() + { + return Apply(new TestEvent(Id)); + } + + protected override TestAggregate When(EventEnvelope envelope) + { + return envelope.Event switch + { + TestEvent testEvent when testEvent.Id == Id => this with { Exists = true }, + _ => this + }; + } + } + + private record TestEvent(int Id) : Event; + + private class AllowAllPolicy : Policy + { + public ValueTask AuthenticateEvent(EventEnvelope envelope, IAuthContext authContext) + { + return ValueTask.FromResult(true); + } + + public ValueTask AuthenticateReadModel(IReadModel readModel, IAuthContext authContext) + { + return ValueTask.FromResult(true); + } + } +} diff --git a/src/Fluss.UnitTest/Core/Upcasting/EventUpcasterServiceTest.cs b/src/Fluss.UnitTest/Core/Upcasting/EventUpcasterServiceTest.cs new file mode 100644 index 0000000..9f695f4 --- /dev/null +++ b/src/Fluss.UnitTest/Core/Upcasting/EventUpcasterServiceTest.cs @@ -0,0 +1,216 @@ +using Fluss.Events; +using Fluss.Upcasting; +using Microsoft.Extensions.Logging; +using Moq; +using Newtonsoft.Json.Linq; + +namespace Fluss.UnitTest.Core.Upcasting; + +public class EventUpcasterServiceTest +{ + private RawEventEnvelope GetRawTestEvent1Envelope(int version) + { + var jObject = new TestEvent1("Value").ToJObject(); + + return new RawEventEnvelope { Version = version, RawEvent = jObject }; + } + + private RawEventEnvelope GetRawTestEvent2Envelope(int version) + { + var jObject = new TestEvent2("Value2").ToJObject(); + + return new RawEventEnvelope { Version = version, RawEvent = jObject }; + } + + private (EventUpcasterService, Mock) GetServices(IEnumerable upcasters, IEnumerable? events = null) + { + var eventRepository = new Mock(); + var logger = new Mock>(); + + var usedEvents = events ?? Enumerable.Range(0, 4).Select(GetRawTestEvent1Envelope); + eventRepository.Setup(repo => repo.GetRawEvents()).Returns(ValueTask.FromResult(usedEvents)); + + return ( + new EventUpcasterService(upcasters, new UpcasterSorter(), eventRepository.Object, logger.Object), + eventRepository + ); + } + + [Fact] + public async Task UpcasterReturningNullDoesntReplaceEvents() + { + var (upcasterService, eventRepositoryMock) = GetServices(new[] { new NoopUpcast() }); + await upcasterService.Run(); + + eventRepositoryMock.Verify( + repo => repo.ReplaceEvent( + It.IsAny(), + It.IsAny>() + ), + Times.Never + ); + } + + [Fact] + public async Task SingleEventsAreUpcast() + { + var (upcasterService, eventRepositoryMock) = GetServices(new[] { new SingleEventUpcast() }); + + await upcasterService.Run(); + + eventRepositoryMock.Verify( + repo => repo.ReplaceEvent( + It.IsAny(), + It.Is>( + newEvents => newEvents.SingleOrDefault()!.RawEvent["Property1"]!.ToObject() == "Upcast" + ) + ), + Times.Exactly(4) + ); + } + + [Fact] + public async Task MultipleEventsAreUpcast() + { + var (upcasterService, eventRepositoryMock) = GetServices(new[] { new MultiEventUpcast() }); + + await upcasterService.Run(); + + eventRepositoryMock.Verify( + repo => repo.ReplaceEvent( + It.IsAny(), + It.Is>(newEvents => newEvents.Count() == 3) + ), + Times.Exactly(4) + ); + } + + [Fact] + public async Task UpcastsAreChainable() + { + var events = Enumerable.Range(0, 4) + .Select(GetRawTestEvent1Envelope) + .Append(GetRawTestEvent2Envelope(4)); + + var (upcasterService, eventRepositoryMock) = + GetServices(new IUpcaster[] { new ChainedEventUpcast2(), new ChainedEventUpcast() }, events); + + await upcasterService.Run(); + + eventRepositoryMock.Verify( + repo => repo.ReplaceEvent( + It.IsAny(), + It.Is>( + newEvents => newEvents.SingleOrDefault()!.RawEvent["Property2"]!.ToObject() == "Value" + ) + ), + Times.Exactly(4) + ); + + eventRepositoryMock.Verify( + repo => repo.ReplaceEvent( + It.IsAny(), + It.Is>( + newEvents => newEvents.SingleOrDefault()!.RawEvent["Property2"]!.ToObject() == "Upcast-Value" + ) + ), + Times.Exactly(4) + ); + + eventRepositoryMock.Verify( + repo => repo.ReplaceEvent( + It.IsAny(), + It.Is>( + newEvents => newEvents.SingleOrDefault()!.RawEvent["Property2"]!.ToObject() == "Upcast-Value2") + ), + Times.Once + ); + } + + [Fact] + public async Task VersionIsSetCorrectly() + { + var (upcasterService, eventRepositoryMock) = GetServices(new[] { new MultiEventUpcast() }, new[] { GetRawTestEvent1Envelope(1) }); + + await upcasterService.Run(); + + eventRepositoryMock.Verify( + repo => repo.ReplaceEvent( + It.IsAny(), + It.Is>( + newEvents => newEvents.Select((envelope, i) => envelope.Version == i).All(b => b) + ) + ), + Times.Once + ); + } +} + +record TestEvent1(string Property1) : Event; + +record TestEvent2(string Property2) : Event; + +class NoopUpcast : IUpcaster +{ + public IEnumerable? Upcast(JObject eventJson) => null; +} + +class SingleEventUpcast : IUpcaster +{ + public IEnumerable? Upcast(JObject eventJson) + { + var type = eventJson.GetValue("$type")?.ToObject(); + + if (type != typeof(TestEvent1).AssemblyQualifiedName) return null; + + var clone = (JObject)eventJson.DeepClone(); + clone["Property1"] = "Upcast"; + + return new[] { clone }; + } +} + +class MultiEventUpcast : IUpcaster +{ + public IEnumerable? Upcast(JObject eventJson) + { + var type = eventJson.GetValue("$type")?.ToObject(); + + if (type != typeof(TestEvent1).AssemblyQualifiedName) return null; + + return new[] { eventJson, eventJson, eventJson }; + } +} + +class ChainedEventUpcast : IUpcaster +{ + public IEnumerable? Upcast(JObject eventJson) + { + var type = eventJson.GetValue("$type")?.ToObject(); + + if (type != typeof(TestEvent1).AssemblyQualifiedName) return null; + + var clone = (JObject)eventJson.DeepClone(); + clone["Property2"] = clone["Property1"]; + clone.Remove("Property1"); + clone["$type"] = typeof(TestEvent2).AssemblyQualifiedName; + + return new[] { clone }; + } +} + +[DependsOn(typeof(ChainedEventUpcast))] +class ChainedEventUpcast2 : IUpcaster +{ + public IEnumerable? Upcast(JObject eventJson) + { + var type = eventJson.GetValue("$type")?.ToObject(); + + if (type != typeof(TestEvent2).AssemblyQualifiedName) return null; + + var clone = (JObject)eventJson.DeepClone(); + clone["Property2"] = "Upcast-" + clone["Property2"]!.ToObject(); + + return new[] { clone }; + } +} diff --git a/src/Fluss.UnitTest/Core/Upcasting/UpcasterSorterTest.cs b/src/Fluss.UnitTest/Core/Upcasting/UpcasterSorterTest.cs new file mode 100644 index 0000000..797305d --- /dev/null +++ b/src/Fluss.UnitTest/Core/Upcasting/UpcasterSorterTest.cs @@ -0,0 +1,83 @@ +using Fluss.Upcasting; +using Newtonsoft.Json.Linq; + +namespace Fluss.UnitTest.Core.Upcasting; + +public class UpcasterSorterTest +{ + private readonly UpcasterSorter _sorter = new(); + + [Fact] + public void SortsUpcastersBasedOnDependencies() + { + var up0 = new ExampleUpcasterNoDeps(); + var up1 = new ExampleUpcasterDeps1(); + var up2 = new ExampleUpcasterDeps2(); + var up3 = new ExampleUpcasterDeps3(); + + var upcasters = new IUpcaster[] { up0, up3, up1, up2 }; + + var sorted = _sorter.SortByDependencies(upcasters); + + Assert.Equal(sorted[0], up0); + Assert.Equal(sorted[1], up1); + Assert.Equal(sorted[2], up2); + Assert.Equal(sorted[3], up3); + } + + [Fact] + public void ThrowsWhenCyclicDependencies() + { + var upcasters = new IUpcaster[] { + new ExampleUpcasterNoDeps(), + new ExampleUpcasterCyclic1(), + new ExampleUpcasterCyclic2() + }; + + Assert.Throws(() => _sorter.SortByDependencies(upcasters)); + } + + [Fact] + public void ThrowsWhenMissingDependencies() + { + var upcasters = new IUpcaster[] { + new ExampleUpcasterDeps3(), + }; + + Assert.Throws(() => _sorter.SortByDependencies(upcasters)); + } +} + +class ExampleUpcasterNoDeps : IUpcaster +{ + public IEnumerable Upcast(JObject eventJson) => throw new NotImplementedException(); +} + +class ExampleUpcasterDeps1 : IUpcaster +{ + public IEnumerable Upcast(JObject eventJson) => throw new NotImplementedException(); +} + +[DependsOn(typeof(ExampleUpcasterDeps1), typeof(ExampleUpcasterNoDeps))] +class ExampleUpcasterDeps2 : IUpcaster +{ + public IEnumerable Upcast(JObject eventJson) => throw new NotImplementedException(); +} + +[DependsOn(typeof(ExampleUpcasterDeps1), typeof(ExampleUpcasterDeps2))] +class ExampleUpcasterDeps3 : IUpcaster +{ + public IEnumerable Upcast(JObject eventJson) => throw new NotImplementedException(); +} + +[DependsOn(typeof(ExampleUpcasterCyclic2))] +class ExampleUpcasterCyclic1 : IUpcaster +{ + public IEnumerable Upcast(JObject eventJson) => throw new NotImplementedException(); +} + +[DependsOn(typeof(ExampleUpcasterCyclic1))] +class ExampleUpcasterCyclic2 : IUpcaster +{ + public IEnumerable Upcast(JObject eventJson) => throw new NotImplementedException(); +} diff --git a/src/Fluss.UnitTest/Fluss.UnitTest.csproj b/src/Fluss.UnitTest/Fluss.UnitTest.csproj new file mode 100644 index 0000000..be5f3b8 --- /dev/null +++ b/src/Fluss.UnitTest/Fluss.UnitTest.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/src/Fluss.UnitTest/Usings.cs b/src/Fluss.UnitTest/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/src/Fluss.UnitTest/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/src/Fluss.sln b/src/Fluss.sln new file mode 100644 index 0000000..1ec6ad1 --- /dev/null +++ b/src/Fluss.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fluss", "Fluss\Fluss.csproj", "{BC1DA60E-491A-491F-A5E2-400686F66CF9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fluss.HotChocolate", "Fluss.HotChocolate\Fluss.HotChocolate.csproj", "{3BF93293-6430-4A5D-8F8C-19E0461BA5FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fluss.PostgreSQL", "Fluss.PostgreSQL\Fluss.PostgreSQL.csproj", "{3FD5F886-E978-4B19-B5CF-D7CBAB28626E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fluss.UnitTest", "Fluss.UnitTest\Fluss.UnitTest.csproj", "{9247DCBA-2B5E-49B5-B2BE-880B839A3693}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fluss.Testing", "Fluss.Testing\Fluss.Testing.csproj", "{EB267687-7C88-4DF4-85E4-ACE3FC41BB73}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BC1DA60E-491A-491F-A5E2-400686F66CF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC1DA60E-491A-491F-A5E2-400686F66CF9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC1DA60E-491A-491F-A5E2-400686F66CF9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC1DA60E-491A-491F-A5E2-400686F66CF9}.Release|Any CPU.Build.0 = Release|Any CPU + {3BF93293-6430-4A5D-8F8C-19E0461BA5FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BF93293-6430-4A5D-8F8C-19E0461BA5FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BF93293-6430-4A5D-8F8C-19E0461BA5FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BF93293-6430-4A5D-8F8C-19E0461BA5FC}.Release|Any CPU.Build.0 = Release|Any CPU + {3FD5F886-E978-4B19-B5CF-D7CBAB28626E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FD5F886-E978-4B19-B5CF-D7CBAB28626E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FD5F886-E978-4B19-B5CF-D7CBAB28626E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FD5F886-E978-4B19-B5CF-D7CBAB28626E}.Release|Any CPU.Build.0 = Release|Any CPU + {9247DCBA-2B5E-49B5-B2BE-880B839A3693}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9247DCBA-2B5E-49B5-B2BE-880B839A3693}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9247DCBA-2B5E-49B5-B2BE-880B839A3693}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9247DCBA-2B5E-49B5-B2BE-880B839A3693}.Release|Any CPU.Build.0 = Release|Any CPU + {EB267687-7C88-4DF4-85E4-ACE3FC41BB73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB267687-7C88-4DF4-85E4-ACE3FC41BB73}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB267687-7C88-4DF4-85E4-ACE3FC41BB73}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB267687-7C88-4DF4-85E4-ACE3FC41BB73}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/Fluss.sln.DotSettings.user b/src/Fluss.sln.DotSettings.user new file mode 100644 index 0000000..6795caa --- /dev/null +++ b/src/Fluss.sln.DotSettings.user @@ -0,0 +1,5 @@ + + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &lt;Fluss.UnitTest&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="/home/enterprize1/Code/atmina/fluss/src/Fluss.UnitTest" Presentation="&lt;Fluss.UnitTest&gt;" /> +</SessionState> + True \ No newline at end of file diff --git a/src/Fluss/Aggregates/Aggregate.cs b/src/Fluss/Aggregates/Aggregate.cs new file mode 100644 index 0000000..57fe4b3 --- /dev/null +++ b/src/Fluss/Aggregates/Aggregate.cs @@ -0,0 +1,20 @@ +using Fluss.Events; + +namespace Fluss.Aggregates; + +public abstract record AggregateRoot : EventListener, IRootEventListener +{ + public UnitOfWork.UnitOfWork UnitOfWork { private get; init; } = null!; + protected abstract override AggregateRoot When(EventEnvelope envelope); + + protected async ValueTask Apply(Event @event) + { + await UnitOfWork.Publish(@event, this); + } +} + +public abstract record AggregateRoot : AggregateRoot, IEventListenerWithKey +{ + public bool Exists { get; protected set; } + public TId Id { get; init; } = default!; +} diff --git a/src/Fluss/Authentication/ArbitraryUserUnitOfWorkExtension.cs b/src/Fluss/Authentication/ArbitraryUserUnitOfWorkExtension.cs new file mode 100644 index 0000000..6900706 --- /dev/null +++ b/src/Fluss/Authentication/ArbitraryUserUnitOfWorkExtension.cs @@ -0,0 +1,66 @@ +using System.Collections.Concurrent; +using Fluss.UnitOfWork; +using Microsoft.Extensions.DependencyInjection; + +namespace Fluss.Authentication; + +public class ArbitraryUserUnitOfWorkCache +{ + private readonly IServiceProvider _serviceProvider; + private readonly ConcurrentDictionary _cache = new(); + + public ArbitraryUserUnitOfWorkCache(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public UnitOfWorkFactory GetUserUnitOfWorkFactory(Guid userId) + { + var sp = GetCachedUserUnitOfWork(userId); + return sp.GetRequiredService(); + } + + public UnitOfWork.UnitOfWork GetUserUnitOfWork(Guid userId) + { + var sp = GetCachedUserUnitOfWork(userId); + return sp.GetRequiredService(); + } + + private IServiceProvider GetCachedUserUnitOfWork(Guid userId) + { + return _cache.GetOrAdd(userId, CreateUserServiceProvider); + } + + private IServiceProvider CreateUserServiceProvider(Guid providedId) + { + var collection = new ServiceCollection(); + var constructorArgumentTypes = typeof(UnitOfWork.UnitOfWork).GetConstructors().Single().GetParameters() + .Select(p => p.ParameterType); + + foreach (var type in constructorArgumentTypes) + { + if (type == typeof(UserIdProvider)) continue; + collection.AddSingleton(type, _serviceProvider.GetService(type)!); + } + + collection.ProvideUserIdFrom(_ => providedId); + collection.AddTransient(); + collection.AddTransient(sp => sp.GetRequiredService()); + collection.AddTransient(); + + return collection.BuildServiceProvider(); + } +} + +public static class ArbitraryUserUnitOfWorkExtension +{ + public static UnitOfWorkFactory GetUserUnitOfWorkFactory(this IServiceProvider serviceProvider, Guid userId) + { + return serviceProvider.GetRequiredService().GetUserUnitOfWorkFactory(userId); + } + + public static UnitOfWork.UnitOfWork GetUserUnitOfWork(this IServiceProvider serviceProvider, Guid userId) + { + return serviceProvider.GetRequiredService().GetUserUnitOfWork(userId); + } +} diff --git a/src/Fluss/Authentication/AuthContext.cs b/src/Fluss/Authentication/AuthContext.cs new file mode 100644 index 0000000..827981b --- /dev/null +++ b/src/Fluss/Authentication/AuthContext.cs @@ -0,0 +1,74 @@ +using Fluss.Events; +using Fluss.ReadModel; +using Fluss.UnitOfWork; + +namespace Fluss.Authentication; + +public interface IAuthContext +{ + public ValueTask CacheAndGet(string key, Func> func); + + public ValueTask GetReadModel() + where TReadModel : EventListener, IRootEventListener, IReadModel, new(); + + public ValueTask GetReadModel(TKey key) + where TReadModel : EventListener, IEventListenerWithKey, IReadModel, new(); + + public ValueTask> + GetMultipleReadModels(IEnumerable keys) where TKey : notnull + where TReadModel : EventListener, IReadModel, IEventListenerWithKey, new(); + + public Guid UserId { get; } +} + +internal class AuthContext : IAuthContext +{ + private readonly IUnitOfWork _unitOfWork; + public readonly Dictionary Data = new(); + public Guid UserId { get; private set; } + + public AuthContext(IUnitOfWork unitOfWork, Guid userId) + { + _unitOfWork = unitOfWork; + UserId = userId; + } + + public async ValueTask CacheAndGet(string key, Func> func) + { + var o = Data.ContainsKey(key) ? Data[key] : null; + + switch (o) + { + case Task task: + return await task; + case T t: + return t; + } + + var newTask = func(); + Data[key] = newTask; + var result = await newTask; + Data[key] = result!; + + return result; + } + + public ValueTask GetReadModel() + where TReadModel : EventListener, IRootEventListener, IReadModel, new() + { + return _unitOfWork.UnsafeGetReadModelWithoutAuthorization(); + } + + public ValueTask GetReadModel(TKey key) + where TReadModel : EventListener, IEventListenerWithKey, IReadModel, new() + { + return _unitOfWork.UnsafeGetReadModelWithoutAuthorization(key); + } + + public ValueTask> + GetMultipleReadModels(IEnumerable keys) where TKey : notnull + where TReadModel : EventListener, IReadModel, IEventListenerWithKey, new() + { + return _unitOfWork.UnsafeGetMultipleReadModelsWithoutAuthorization(keys); + } +} diff --git a/src/Fluss/Authentication/Policy.cs b/src/Fluss/Authentication/Policy.cs new file mode 100644 index 0000000..8056ef9 --- /dev/null +++ b/src/Fluss/Authentication/Policy.cs @@ -0,0 +1,17 @@ +using Fluss.Events; +using Fluss.ReadModel; + +namespace Fluss.Authentication; + +public interface Policy +{ + public ValueTask AuthenticateEvent(EventEnvelope envelope, IAuthContext authContext) + { + return ValueTask.FromResult(false); + } + + public ValueTask AuthenticateReadModel(IReadModel readModel, IAuthContext authContext) + { + return ValueTask.FromResult(false); + } +} diff --git a/src/Fluss/Authentication/ServiceCollectionExtensions.cs b/src/Fluss/Authentication/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..05f2360 --- /dev/null +++ b/src/Fluss/Authentication/ServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace Fluss.Authentication; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddPoliciesFrom(this IServiceCollection services, Assembly assembly) + { + var policyTypes = assembly.GetTypes().Where(x => x.IsAssignableTo(typeof(Policy))); + + foreach (var policyType in policyTypes) + { + services = services + .AddScoped(policyType) + .AddScoped(sp => (Policy)sp.GetRequiredService(policyType)); + } + + return services; + } + + public static IServiceCollection ProvideUserIdFrom(this IServiceCollection services, + Func func) + { + return services.AddSingleton(sp => new UserIdProvider(func, sp)); + } +} diff --git a/src/Fluss/Authentication/SystemUser.cs b/src/Fluss/Authentication/SystemUser.cs new file mode 100644 index 0000000..dafac07 --- /dev/null +++ b/src/Fluss/Authentication/SystemUser.cs @@ -0,0 +1,23 @@ +using Fluss.UnitOfWork; + +namespace Fluss.Authentication; + +public static class SystemUser +{ + public static readonly Guid SystemUserGuid = Guid.Parse("9c4749e6-1b58-4ef3-8573-930cd617b181"); + + public static UnitOfWorkFactory GetSystemUserUnitOfWorkFactory(this IServiceProvider serviceProvider) + { + return serviceProvider.GetUserUnitOfWorkFactory(SystemUserGuid); + } + + public static UnitOfWork.UnitOfWork GetSystemUserUnitOfWork(this IServiceProvider serviceProvider) + { + return serviceProvider.GetUserUnitOfWork(SystemUserGuid); + } + + public static bool IsSystemUser(this IAuthContext authContext) + { + return authContext.UserId == SystemUserGuid; + } +} diff --git a/src/Fluss/Authentication/UserIdProvider.cs b/src/Fluss/Authentication/UserIdProvider.cs new file mode 100644 index 0000000..7d226ea --- /dev/null +++ b/src/Fluss/Authentication/UserIdProvider.cs @@ -0,0 +1,18 @@ +namespace Fluss.Authentication; + +public class UserIdProvider +{ + private readonly Func _func; + private readonly IServiceProvider _serviceProvider; + + public UserIdProvider(Func func, IServiceProvider serviceProvider) + { + _func = func; + _serviceProvider = serviceProvider; + } + + public Guid Get() + { + return _func(_serviceProvider); + } +} diff --git a/src/Fluss/Events/Event.cs b/src/Fluss/Events/Event.cs new file mode 100644 index 0000000..09b7257 --- /dev/null +++ b/src/Fluss/Events/Event.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Fluss.Events; + +public interface Event +{ +} + +public static class EventExtension +{ + public static JObject ToJObject(this Event @event) + { + var serializer = new JsonSerializer { TypeNameHandling = TypeNameHandling.All, TypeNameAssemblyFormatHandling = TypeNameAssemblyFormatHandling.Full }; + return JObject.FromObject(@event, serializer); + } +} diff --git a/src/Fluss/Events/EventEnvelope.cs b/src/Fluss/Events/EventEnvelope.cs new file mode 100644 index 0000000..a1e1822 --- /dev/null +++ b/src/Fluss/Events/EventEnvelope.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json.Linq; + +namespace Fluss.Events; + +public abstract record Envelope +{ + public long Version { get; init; } + + public DateTimeOffset At { get; init; } = DateTimeOffset.UtcNow; + public Guid? By { get; init; } +} + +public record RawEventEnvelope : Envelope +{ + public required JObject RawEvent { get; init; } +} + +public record EventEnvelope : Envelope +{ + public required Event Event { get; init; } +} diff --git a/src/Fluss/Events/EventListener.cs b/src/Fluss/Events/EventListener.cs new file mode 100644 index 0000000..e10bfc0 --- /dev/null +++ b/src/Fluss/Events/EventListener.cs @@ -0,0 +1,80 @@ +using Fluss.Events.TransientEvents; + +namespace Fluss.Events; + +public abstract record EventListener +{ + internal EventListenerVersionTag Tag = new(-1); + protected abstract EventListener When(EventEnvelope envelope); + + internal EventListener WhenInt(EventEnvelope envelope) + { +#if DEBUG + if (envelope.Version != Tag.LastSeen + 1 && envelope is not TransientEventEnvelope) + { + throw new InvalidOperationException( + $"Event envelope version {envelope.Version} is not the next expected version {Tag.LastSeen + 1}."); + } +#endif + + var newEventListener = When(envelope); + + var changed = newEventListener != this; + + if (envelope.Event is TransientEvent) + { + if (newEventListener.Tag.HasTaint()) + { + newEventListener.Tag.LastSeenTransient = envelope.Version; + } + else + { + newEventListener = newEventListener with { Tag = Tag with { LastSeenTransient = envelope.Version } }; + } + + return newEventListener; + } + + if (changed) + { + newEventListener = newEventListener with { Tag = new EventListenerVersionTag(envelope.Version) }; + } + else + { + newEventListener.Tag.LastSeen = envelope.Version; + } + + return newEventListener; + } +} + +public record EventListenerVersionTag +{ + /// Last Event that was consumed by this EventListener + public long LastSeen = -1; + /// Last Event that mutated this EventListener + public long LastAccepted = -1; + /// Last TransientEvent that was consumed by this EventListener + public long LastSeenTransient = -1; + + public EventListenerVersionTag(long version, long lastSeenTransient = -1) + { + LastSeen = version; + LastAccepted = version; + LastSeenTransient = lastSeenTransient; + } + + public bool HasTaint() + { + return LastSeenTransient > -1; + } +} + +public interface IRootEventListener +{ +} + +public interface IEventListenerWithKey +{ + public TKey Id { get; init; } +} \ No newline at end of file diff --git a/src/Fluss/Events/EventListenerFactory.cs b/src/Fluss/Events/EventListenerFactory.cs new file mode 100644 index 0000000..77caedf --- /dev/null +++ b/src/Fluss/Events/EventListenerFactory.cs @@ -0,0 +1,58 @@ +using System.Collections.ObjectModel; +using System.Diagnostics.Contracts; + +namespace Fluss.Events; + +public sealed class EventListenerFactory : IEventListenerFactory +{ + private readonly IEventRepository _eventRepository; + + public EventListenerFactory(IEventRepository eventRepository) + { + _eventRepository = eventRepository; + } + + public async ValueTask UpdateTo(TEventListener eventListener, long to) where TEventListener : EventListener + { + var events = await _eventRepository.GetEvents(eventListener.Tag.LastSeen, to); + + return UpdateWithEvents(eventListener, events); + } + + public TEventListener UpdateWithEvents(TEventListener eventListener, ReadOnlyCollection> events) where TEventListener : EventListener + { + EventListener updatedEventListener = eventListener; + // ReSharper disable once ForCanBeConvertedToForeach + for (var i = 0; i < events.Count; i++) + { + var eventSpan = events[i].Span; + // ReSharper disable once ForCanBeConvertedToForeach + for (var j = 0; j < eventSpan.Length; j++) + { + updatedEventListener = updatedEventListener.WhenInt(eventSpan[j]); + } + } + + return (TEventListener)updatedEventListener; + } +} + +public interface IEventListenerFactory +{ + [Pure] + ValueTask UpdateTo(TEventListener eventListener, long to) where TEventListener : EventListener; + + TEventListener UpdateWithEvents(TEventListener eventListener, ReadOnlyCollection> events) + where TEventListener : EventListener; +} + +public abstract class EventListenerFactoryPipeline : IEventListenerFactory +{ + protected internal IEventListenerFactory Next = null!; + public abstract ValueTask UpdateTo(TEventListener eventListener, long to) where TEventListener : EventListener; + public virtual TEventListener UpdateWithEvents(TEventListener eventListener, + ReadOnlyCollection> events) where TEventListener : EventListener + { + return Next.UpdateWithEvents(eventListener, events); + } +} diff --git a/src/Fluss/Events/EventMemoryArrayExtensions.cs b/src/Fluss/Events/EventMemoryArrayExtensions.cs new file mode 100644 index 0000000..165d0b9 --- /dev/null +++ b/src/Fluss/Events/EventMemoryArrayExtensions.cs @@ -0,0 +1,44 @@ +using System.Collections.ObjectModel; + +namespace Fluss.Events; + +public static class EventMemoryArrayExtensions +{ + public static ReadOnlyCollection> ToPagedMemory(this List envelopes) where T : EventEnvelope + { + if (envelopes is List casted) + { + return new[] { + casted.ToArray().AsMemory().AsReadOnly() + }.AsReadOnly(); + } + else + { + return new[] { + envelopes.Cast().ToArray().AsMemory().AsReadOnly() + }.AsReadOnly(); + } + } + + public static IReadOnlyList ToFlatEventList(this ReadOnlyCollection> pagedMemory) + { + var result = new List(); + + foreach (var memory in pagedMemory) + { + result.AddRange(memory.ToArray()); + } + + return result; + } + + public static async ValueTask> ToFlatEventList(this ValueTask>> pagedMemory) + { + return (await pagedMemory).ToFlatEventList(); + } + + public static ReadOnlyMemory AsReadOnly(this Memory memory) + { + return memory; + } +} diff --git a/src/Fluss/Events/EventRepository.cs b/src/Fluss/Events/EventRepository.cs new file mode 100644 index 0000000..e2e899d --- /dev/null +++ b/src/Fluss/Events/EventRepository.cs @@ -0,0 +1,54 @@ +using System.Collections.ObjectModel; + +namespace Fluss.Events; + +public interface IEventRepository +{ + event EventHandler NewEvents; + ValueTask Publish(IEnumerable events); + ValueTask>> GetEvents(long fromExclusive, long toInclusive); + ValueTask> GetRawEvents(); + ValueTask ReplaceEvent(long version, IEnumerable newEvents); + ValueTask GetLatestVersion(); +} + +public interface IBaseEventRepository : IEventRepository +{ +} + +public abstract class EventRepositoryPipeline : IEventRepository +{ + protected internal IEventRepository Next = null!; + + public virtual ValueTask>> GetEvents(long fromExclusive, + long toInclusive) + { + return Next.GetEvents(fromExclusive, toInclusive); + } + + public virtual ValueTask ReplaceEvent(long version, IEnumerable newEvents) + { + return Next.ReplaceEvent(version, newEvents); + } + + public virtual ValueTask> GetRawEvents() + { + return Next.GetRawEvents(); + } + + public virtual ValueTask GetLatestVersion() + { + return Next.GetLatestVersion(); + } + + public virtual event EventHandler NewEvents + { + add => Next.NewEvents += value; + remove => Next.NewEvents -= value; + } + + public virtual ValueTask Publish(IEnumerable events) + { + return Next.Publish(events); + } +} diff --git a/src/Fluss/Events/InMemoryEventCache.cs b/src/Fluss/Events/InMemoryEventCache.cs new file mode 100644 index 0000000..5b7dd35 --- /dev/null +++ b/src/Fluss/Events/InMemoryEventCache.cs @@ -0,0 +1,136 @@ +using System.Collections.ObjectModel; + +namespace Fluss.Events; + +public class InMemoryEventCache : EventRepositoryPipeline +{ + private readonly long _cacheSizePerItem; + private readonly List _cache = new(); + private long _lastKnownVersion = -1; + private readonly SemaphoreSlim _loadLock = new(1, 1); + + public InMemoryEventCache(long cacheSizePerItem = 10_000) + { + _cacheSizePerItem = cacheSizePerItem; + } + + public override async ValueTask>> GetEvents(long fromExclusive, + long toInclusive) + { + using var activity = FlussActivitySource.Source.StartActivity(); + activity?.SetTag("EventSourcing.EventRequest", $"{fromExclusive}-{toInclusive}"); + activity?.SetTag("EventSourcing.EventRepository", nameof(InMemoryEventCache)); + + if (fromExclusive >= toInclusive) + { + return Array.Empty>().AsReadOnly(); + } + + await EnsureEventsLoaded(toInclusive); + + var fromItemId = GetCacheKey(fromExclusive + 1); + var toItemId = GetCacheKey(toInclusive); + + if (fromItemId == toItemId) + { + return new[] { + _cache[fromItemId] + .AsMemory((int)((fromExclusive + 1) % _cacheSizePerItem), (int)(toInclusive - fromExclusive)).AsReadOnly() + }.AsReadOnly(); + } + + var result = new ReadOnlyMemory[toItemId - fromItemId + 1]; + + result[0] = _cache[fromItemId].AsMemory((int)((fromExclusive + 1) % _cacheSizePerItem)); + for (var i = fromItemId + 1; i < toItemId; i++) + { + result[i - fromItemId] = _cache[i].AsMemory(); + } + + result[^1] = _cache[toItemId].AsMemory(0, (int)(toInclusive % _cacheSizePerItem) + 1); + + return result.AsReadOnly(); + } + + private async Task EnsureEventsLoaded(long to) + { + if (_lastKnownVersion >= to) + { + return; + } + + await _loadLock.WaitAsync(); + + try + { + if (_lastKnownVersion >= to) + { + return; + } + + AddEvents(await base.GetEvents(_lastKnownVersion, to)); + } + finally + { + _loadLock.Release(); + } + } + + public override async ValueTask Publish(IEnumerable events) + { + using var activity = FlussActivitySource.Source.StartActivity(); + activity?.SetTag("EventSourcing.EventRepository", nameof(InMemoryEventCache)); + + var eventEnvelopes = events.ToList(); + await base.Publish(eventEnvelopes); + + await _loadLock.WaitAsync(); + try + { + AddEvents(new[] { eventEnvelopes.ToArray().AsMemory().AsReadOnly() }.AsReadOnly()); + } + finally + { + _loadLock.Release(); + } + } + + private int GetCacheKey(long i) + { + return (int)(i / _cacheSizePerItem); + } + + private long MinItemForCache(int itemId) + { + return _cacheSizePerItem * itemId; + } + + private void AddEvents(ReadOnlyCollection> eventEnvelopes) + { + using var activity = FlussActivitySource.Source.StartActivity(); + + foreach (var eventEnvelopeMemory in eventEnvelopes) + { + var readOnlySpan = eventEnvelopeMemory.Span; + + // ReSharper disable once ForCanBeConvertedToForeach + for (var index = 0; index < readOnlySpan.Length; index++) + { + var eventEnvelope = readOnlySpan[index]; + var cacheKey = GetCacheKey(eventEnvelope.Version); + while (_cache.Count <= cacheKey) + { + _cache.Add(new EventEnvelope[_cacheSizePerItem]); + } + + _cache[cacheKey][eventEnvelope.Version - MinItemForCache(cacheKey)] = eventEnvelope; + + // AddEvents could be executed out of order, so we only update the lastKnownVersion if we are sure, that all events before are fetched + if (_lastKnownVersion == eventEnvelope.Version - 1) + { + _lastKnownVersion = eventEnvelope.Version; + } + } + } + } +} diff --git a/src/Fluss/Events/InMemoryEventListenerCache.cs b/src/Fluss/Events/InMemoryEventListenerCache.cs new file mode 100644 index 0000000..7ea2059 --- /dev/null +++ b/src/Fluss/Events/InMemoryEventListenerCache.cs @@ -0,0 +1,88 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace Fluss.Events; + +public class InMemoryEventListenerCache : EventListenerFactoryPipeline +{ + private MemoryCache _cache = new(new MemoryCacheOptions()); + + public override async ValueTask UpdateTo(TEventListener eventListener, long to) + { + var cached = Retrieve(eventListener, to); + + if (cached.Tag.LastSeen == to) + { + return cached; + } + + var newEventListener = await Next.UpdateTo(cached, to); + if (!newEventListener.Tag.HasTaint()) + { + Store(newEventListener); + } + + return newEventListener; + } + + private TEventListener Retrieve(TEventListener eventListener, long before) + where TEventListener : EventListener + { + + var key = GetKey(eventListener); + if (!_cache.TryGetValue(key, out var cached) || cached == null) + { + return eventListener; + } + + var cachedEntry = (TEventListener)cached; + if (cachedEntry.Tag.LastAccepted > before) + { + return eventListener; + } + + return CloneTag(cachedEntry); + } + + private void Store(TEventListener newEventListener) where TEventListener : EventListener + { + var key = GetKey(newEventListener); + + if (!_cache.TryGetValue(key, out var cached) || cached == null) + { + _cache.Set(key, newEventListener, new MemoryCacheEntryOptions { Size = 1 }); + return; + } + + var cachedEntry = (TEventListener)cached; + if (newEventListener.Tag.LastSeen <= cachedEntry.Tag.LastSeen) + { + return; + } + + _cache.Set(key, CloneTag(newEventListener), new MemoryCacheEntryOptions { Size = 1 }); + } + + private TEventListener CloneTag(TEventListener eventListener) + where TEventListener : EventListener + { + return eventListener with { Tag = eventListener.Tag with { } }; + } + + private object GetKey(EventListener eventListener) + { + if (eventListener.GetType().GetInterfaces().Any(x => + x.IsGenericType && + x.GetGenericTypeDefinition() == typeof(IEventListenerWithKey<>))) + { + return (eventListener.GetType(), eventListener.GetType().GetProperty("Id")?.GetValue(eventListener)); + } + + return eventListener.GetType(); + } + + public void Clean() + { + _cache.Dispose(); + _cache = new MemoryCache(new MemoryCacheOptions()); + } +} diff --git a/src/Fluss/Events/InMemoryEventRepository.cs b/src/Fluss/Events/InMemoryEventRepository.cs new file mode 100644 index 0000000..a85519e --- /dev/null +++ b/src/Fluss/Events/InMemoryEventRepository.cs @@ -0,0 +1,101 @@ +using System.Collections.ObjectModel; +using Fluss.Exceptions; +using Newtonsoft.Json; + +namespace Fluss.Events; + +public class InMemoryEventRepository : IBaseEventRepository +{ + private readonly List _events = new(); + public event EventHandler? NewEvents; + + public ValueTask Publish(IEnumerable eventEnvelopes) + { + foreach (var eventEnvelope in eventEnvelopes) + { + lock (_events) + { + if (eventEnvelope.Version != _events.Count) + { + throw new RetryException(); + } + + _events.Add(eventEnvelope); + } + } + + NotifyNewEvents(); + return ValueTask.CompletedTask; + } + + public ValueTask>> GetEvents(long fromExclusive, long toInclusive) + { + if (toInclusive < fromExclusive) + { + throw new InvalidOperationException("From is greater than to"); + } + + return ValueTask.FromResult( + new[] { + _events.ToArray().AsMemory((int)fromExclusive + 1, (int)(toInclusive - fromExclusive)).AsReadOnly() + }.AsReadOnly()); + } + + public ValueTask> GetRawEvents() + { + var rawEnvelopes = _events.Select(envelope => + { + var rawEvent = envelope.Event.ToJObject(); + + return new RawEventEnvelope + { + By = envelope.By, + Version = envelope.Version, + At = envelope.At, + RawEvent = rawEvent, + }; + }); + + return ValueTask.FromResult(rawEnvelopes); + } + + public ValueTask ReplaceEvent(long at, IEnumerable newEvents) + { + var checkedAt = checked((int)at); + var events = newEvents.ToList(); + + for (var i = checkedAt; i < _events.Count; i++) + { + _events[i] = _events[i] with { Version = _events[i].Version + events.Count - 1 }; + } + + _events.RemoveAt(checkedAt); + var serializer = new JsonSerializer { TypeNameHandling = TypeNameHandling.All }; + + var eventEnvelopes = events.Select(envelope => + { + var @event = envelope.RawEvent.ToObject(serializer); + + if (@event is null) throw new Exception("Failed to convert raw event to Event"); + + return new EventEnvelope { By = envelope.By, At = envelope.At, Version = envelope.Version, Event = @event }; + }); + + _events.InsertRange(checkedAt, eventEnvelopes); + + return ValueTask.CompletedTask; + } + + public ValueTask GetLatestVersion() + { + return ValueTask.FromResult(_events.Count - 1); + } + + private void NotifyNewEvents() + { + Task.Run(() => + { + NewEvents?.Invoke(this, EventArgs.Empty); + }); + } +} diff --git a/src/Fluss/Events/TransientEvents/TransientEvent.cs b/src/Fluss/Events/TransientEvents/TransientEvent.cs new file mode 100644 index 0000000..62a2433 --- /dev/null +++ b/src/Fluss/Events/TransientEvents/TransientEvent.cs @@ -0,0 +1,16 @@ +namespace Fluss.Events.TransientEvents; + +/// An Event containing temporary information. +/// A TransientEvent will not be persisted. +public interface TransientEvent : Event { } + +public record TransientEventEnvelope : EventEnvelope +{ + public DateTimeOffset ExpiresAt { get; init; } +} + +public class ExpiresAfterAttribute : Attribute +{ + public double Ms { get; } + public ExpiresAfterAttribute(double ms) => this.Ms = ms; +} diff --git a/src/Fluss/Events/TransientEvents/TransientEventAwareEventListenerFactory.cs b/src/Fluss/Events/TransientEvents/TransientEventAwareEventListenerFactory.cs new file mode 100644 index 0000000..6d47d73 --- /dev/null +++ b/src/Fluss/Events/TransientEvents/TransientEventAwareEventListenerFactory.cs @@ -0,0 +1,19 @@ +namespace Fluss.Events.TransientEvents; + +public class TransientEventAwareEventListenerFactory : EventListenerFactoryPipeline +{ + private readonly TransientEventAwareEventRepository _transientEventRepository; + + public TransientEventAwareEventListenerFactory(TransientEventAwareEventRepository transientEventRepository) + { + _transientEventRepository = transientEventRepository; + } + + public override async ValueTask UpdateTo(TEventListener eventListener, long to) + { + var next = await Next.UpdateTo(eventListener, to); + var transientEvents = _transientEventRepository.GetCurrentTransientEvents(); + + return UpdateWithEvents(next, transientEvents); + } +} diff --git a/src/Fluss/Events/TransientEvents/TransientEventAwareEventRepository.cs b/src/Fluss/Events/TransientEvents/TransientEventAwareEventRepository.cs new file mode 100644 index 0000000..81f721c --- /dev/null +++ b/src/Fluss/Events/TransientEvents/TransientEventAwareEventRepository.cs @@ -0,0 +1,120 @@ +using System.Collections.ObjectModel; +using System.Reflection; + +namespace Fluss.Events.TransientEvents; + +public sealed class TransientEventAwareEventRepository : EventRepositoryPipeline +{ + private readonly List _transientEvents = new(); + private long _transientEventVersion; + private bool _cleanTaskIsRunning = false; + private bool _anotherCleanTaskRequired = false; + + public event EventHandler? NewTransientEvents; + + public ReadOnlyCollection> GetCurrentTransientEvents() + { + lock (this) + { + var result = _transientEvents.ToPagedMemory(); + CleanEvents(); + return result; + } + } + + public override async ValueTask Publish(IEnumerable events) + { + var eventEnvelopes = events.ToList(); + if (!eventEnvelopes.Any()) return; + + var transientEventEnvelopes = eventEnvelopes.Where(e => e.Event is TransientEvent); + + // Reset version of persisted events to ensure cache functionality using the first Version received as baseline + // We can safely fall back to -1 here, since the value will not be used as no events are being published + var firstPersistedVersion = eventEnvelopes.FirstOrDefault()?.Version ?? -1; + + var persistedEventEnvelopes = eventEnvelopes + .Where(e => e.Event is not TransientEvent) + .Select((e, i) => e with { Version = firstPersistedVersion + i }); + + await base.Publish(persistedEventEnvelopes); + PublishTransientEvents(transientEventEnvelopes); + } + + private void PublishTransientEvents(IEnumerable events) + { + var eventList = events.ToList(); + if (eventList.Count == 0) return; + + var now = DateTimeOffset.Now; + + lock (this) + { + var newEvents = eventList.Select(e => + { + if (e.Event is not TransientEvent transientEvent) return null; + + var expiresAfter = e.Event.GetType().GetCustomAttribute()?.Ms ?? 0; + var expiresAt = now.AddMilliseconds(expiresAfter); + + return new TransientEventEnvelope + { + ExpiresAt = expiresAt, + Event = transientEvent, + At = e.At, + By = e.By, + Version = _transientEventVersion++, + }; + }).OfType().ToList(); + + _transientEvents.AddRange(newEvents); + + NewTransientEvents?.Invoke(this, EventArgs.Empty); + } + } + + // This is supposed to act as a 100ms debounce for cleaning the events. Only if the currently active token is not + // cancelled after 100ms will the transient events be cleared. This does not invalidate expired events as close + // to their expiration time as possible however this is the lesser worry compared to not all listeners getting + // all events before they are cleaned up. + private void CleanEvents() + { + lock (this) + { + if (_cleanTaskIsRunning) + { + _anotherCleanTaskRequired = true; + return; + } + + // Starting the clean task after the lock + _cleanTaskIsRunning = true; + } + + var now = DateTimeOffset.Now; + + // ReSharper disable once AsyncVoidLambda + new Task(async () => + { + while (true) + { + await Task.Delay(100); + + lock (this) + { + if (_anotherCleanTaskRequired) + { + _anotherCleanTaskRequired = false; + now = DateTimeOffset.Now; + } + else + { + _transientEvents.RemoveAll(e => e.ExpiresAt < now); + _cleanTaskIsRunning = false; + return; + } + } + } + }).Start(); + } +} diff --git a/src/Fluss/Exceptions/RetryException.cs b/src/Fluss/Exceptions/RetryException.cs new file mode 100644 index 0000000..917a260 --- /dev/null +++ b/src/Fluss/Exceptions/RetryException.cs @@ -0,0 +1,6 @@ +namespace Fluss.Exceptions; + +public class RetryException : Exception +{ + public RetryException() : base("This operation needs to be retried") { } +} diff --git a/src/Fluss/Extensions/ValueTaskExtensions.cs b/src/Fluss/Extensions/ValueTaskExtensions.cs new file mode 100644 index 0000000..53dda75 --- /dev/null +++ b/src/Fluss/Extensions/ValueTaskExtensions.cs @@ -0,0 +1,42 @@ +namespace Fluss.Extensions; + +public static class ValueTaskExtensions +{ + public static async ValueTask AnyAsync(this IEnumerable> valueTasks) + { + foreach (var valueTask in valueTasks) + { + if (await valueTask) + { + return true; + } + } + + return false; + } + + public static async ValueTask AllAsync(this IEnumerable> valueTasks) + { + foreach (var valueTask in valueTasks) + { + if (!await valueTask) + { + return false; + } + } + + return true; + } + + public static T GetResult(this ValueTask valueTask) + { + var task = Task.Run(async () => await valueTask); + return task.GetAwaiter().GetResult(); + } + + public static void GetResult(this ValueTask valueTask) + { + var task = Task.Run(async () => await valueTask); + task.GetAwaiter().GetResult(); + } +} diff --git a/src/Fluss/Fluss.csproj b/src/Fluss/Fluss.csproj new file mode 100644 index 0000000..a13d832 --- /dev/null +++ b/src/Fluss/Fluss.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + Fluss + ATMINA Solutions GmbH + + + + + + + + + + + + + diff --git a/src/Fluss/FlussActivitySource.cs b/src/Fluss/FlussActivitySource.cs new file mode 100644 index 0000000..fb24173 --- /dev/null +++ b/src/Fluss/FlussActivitySource.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; +using OpenTelemetry.Trace; + +namespace Fluss; + +internal static class FlussActivitySource +{ + public static ActivitySource Source { get; } = new(GetName(), GetVersion()); + + public static string GetName() + => typeof(FlussActivitySource).Assembly.GetName().Name!; + + private static string GetVersion() + => typeof(FlussActivitySource).Assembly.GetName().Version!.ToString(); +} + +public static class TracerProviderBuilderExtensions +{ + public static TracerProviderBuilder AddFlussInstrumentation( + this TracerProviderBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.AddSource(FlussActivitySource.GetName()); + return builder; + } +} diff --git a/src/Fluss/README.md b/src/Fluss/README.md new file mode 100644 index 0000000..87c39b9 --- /dev/null +++ b/src/Fluss/README.md @@ -0,0 +1,82 @@ +## Key-Subjects + +### Event-Sourcing + +Instead of a database or other storage mechanism in an event-sourced system the application state is not explicitly +recorded, but rather infered from a series of facts from the past. A common example is a banking ledger where we don't +want to save the current amount of money in the account, but rather the list of transactions. The money in the account +is then simply the sum of all transactions. + +This also easily allows a later change of business rules. For example one could define all deposits on a sunday to be +worth double and no data would need to be changed, just the business rules. + +### CQRS / CQS + +Command–query separation requires the separation of the read and write parts of an application. This is an added side +effect of most event-sourcing implementations and allows one to find a good data representation for read and write which +would usually require a tradeoff between them. + +### Command + +Request for a fact to be recorded as an event by a client. This needs to be validated against business rules and if that +succeeds can be transformed into one or multiple events. + +### Event + +A timestamped fact of history that is defined as valid. The primary key (version) is an ever-increasing number. + +### EventRepository + +Provides a way of saving and getting a list of events, as well as notifications for new events coming in. There is +currently an in-memory implementation as well as a postgres implementation. + +WIP: Filtering of the events based on aggregate or readmodel to reduce number of uninteresting events being queried. +This idea prevents using a more traditional event database. + +### ReadModel + +For the read part of the application this allows us to get to the state of the application. The state is saved as fields +on the object and the `When` method is called for every event. In general this implements a fold operation from +functional programming languages. The contract for providing snapshots and change detection with the `When` method is +that a call to `Accepted` happens whenever an event changed the state of the ReadModel (This does not have to be 100% +precise, but more precision allows higher accuracy). + +### Aggregate + +For the write part of the application this provides the aforementioned transformation of Commands into Events. Commands +for an Aggregate are encoded as methods on the Aggregate. To record the Event the `Apply` method has to be called on the +aggregate. + +### UnitOfWork + +This is follows the unit of work pattern and provides values for both read and write parts of the application. For both +of them it holds a `ConsistentVersion` to not have one part of the read models or aggregates use different facts of +history. + +For the read side it allows developers to get a ReadModel at that consistent version. + +For the write side it tracks the list of events returned from aggregates and as such provides a model for a transaction +in the form of a list of events that can either be published as whole or not. + +### Cross Aggregate Validation (WIP) + +Currently it is not easily possible to validate Commands using state from other aggregates. One example is checking for +the uniqueness of a username during registration. We need to find a way to do this. + +### Snapshots (WIP) + +At the start the number of requested events for finding the current state of a read model should be still fine, but at +some point we need to introduce the concept of snapshots and a new snapshot repository for that. Then we can get the +latest read model from the snapshots and only apply the events after the snapshot. + +### SideEffect (WIP) + +Some of the events require external actions to be taken that are listening to our events i. e. sending an e-mail after +registration. A system that tracks and executes these side effects would allow us to decouple the logic for that from +the rest of the application. + +### Upcaster (WIP) + +At some point we will need to update our event definitions to included more or different data. When there are two +versions of a UserRegistrated event all readmodels interested in users registering need to listen to both of them. +Upcasters allow us to centralize that logic. diff --git a/src/Fluss/ReadModel/ReadModel.cs b/src/Fluss/ReadModel/ReadModel.cs new file mode 100644 index 0000000..3b66e7a --- /dev/null +++ b/src/Fluss/ReadModel/ReadModel.cs @@ -0,0 +1,16 @@ +using Fluss.Events; + +namespace Fluss.ReadModel; + +public interface IReadModel +{ +} + +public abstract record RootReadModel : EventListener, IRootEventListener, IReadModel +{ +} + +public abstract record ReadModelWithKey : EventListener, IEventListenerWithKey, IReadModel +{ + public virtual TId Id { get; init; } = default!; +} diff --git a/src/Fluss/ServiceCollectionExtensions.cs b/src/Fluss/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..9f318b6 --- /dev/null +++ b/src/Fluss/ServiceCollectionExtensions.cs @@ -0,0 +1,99 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Fluss.Authentication; +using Fluss.Events; +using Fluss.Events.TransientEvents; +using Fluss.UnitOfWork; +using Fluss.Upcasting; +using Microsoft.Extensions.DependencyInjection; + +[assembly: InternalsVisibleTo("Fluss.UnitTest")] +[assembly: InternalsVisibleTo("Fluss.HotChocolate")] +[assembly: InternalsVisibleTo("Fluss.Testing")] + +namespace Fluss.Core; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddEventSourcing(this IServiceCollection services, bool addCaching = true) + { + ArgumentNullException.ThrowIfNull(services); + + services + .AddBaseEventRepository() + .AddSingleton() + .AddSingleton() + .AddSingleton(sp => + { + var pipeline = sp.GetServices(); + IEventListenerFactory eventListenerFactory = sp.GetRequiredService(); + foreach (var pipelineItem in pipeline) + { + pipelineItem.Next = eventListenerFactory; + eventListenerFactory = pipelineItem; + } + + return eventListenerFactory; + }) + .AddSingleton() + .AddTransient() + .AddTransient(sp => sp.GetRequiredService()) + .AddTransient(); + + if (addCaching) + { + services.AddEventRepositoryPipeline(); + } + + services + .AddEventRepositoryPipeline() + .AddSingleton(); + + return services; + } + + public static IServiceCollection AddEventRepositoryPipeline(this IServiceCollection services) + where TEventRepository : EventRepositoryPipeline + { + return services + .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()); + } + + public static IServiceCollection AddBaseEventRepository(this IServiceCollection services) + where TBaseEventRepository : class, IBaseEventRepository + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + return services + .AddSingleton() + .AddSingleton(sp => + { + var pipeline = sp.GetServices(); + IEventRepository eventRepository = sp.GetRequiredService(); + foreach (var pipelineItem in pipeline) + { + pipelineItem.Next = eventRepository; + eventRepository = pipelineItem; + } + + return eventRepository; + }); + } + + public static IServiceCollection AddUpcasters(this IServiceCollection services, Assembly sourceAssembly) + { + var upcasterType = typeof(IUpcaster); + var upcasters = sourceAssembly.GetTypes().Where(t => t.IsAssignableTo(upcasterType)); + + foreach (var upcaster in upcasters) + { + services.AddScoped(upcasterType, upcaster); + } + + return services.AddSingleton().AddSingleton(); + } +} diff --git a/src/Fluss/SideEffects/SideEffect.cs b/src/Fluss/SideEffects/SideEffect.cs new file mode 100644 index 0000000..b69b7bd --- /dev/null +++ b/src/Fluss/SideEffects/SideEffect.cs @@ -0,0 +1,12 @@ +using Fluss.Events; + +namespace Fluss.SideEffects; + +public interface SideEffect +{ +} + +public interface SideEffect : SideEffect where T : Event +{ + public Task> HandleAsync(T @event, UnitOfWork.UnitOfWork unitOfWork); +} diff --git a/src/Fluss/SideEffects/SideEffectDispatcher.cs b/src/Fluss/SideEffects/SideEffectDispatcher.cs new file mode 100644 index 0000000..ef18125 --- /dev/null +++ b/src/Fluss/SideEffects/SideEffectDispatcher.cs @@ -0,0 +1,158 @@ +using System.Reflection; +using Fluss.Authentication; +using Fluss.Events; +using Fluss.Events.TransientEvents; +using Fluss.Upcasting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Fluss.SideEffects; + +public sealed class SideEffectDispatcher : IHostedService +{ + private readonly Dictionary> _sideEffectCache = new(); + + private readonly IServiceProvider _serviceProvider; + private readonly TransientEventAwareEventRepository _transientEventRepository; + + private long _persistedVersion = -1; + private long _transientVersion = -1; + + private readonly SemaphoreSlim _dispatchLock = new(1, 1); + private readonly ILogger _logger; + + public SideEffectDispatcher(IEnumerable events, IServiceProvider serviceProvider, + TransientEventAwareEventRepository transientEventRepository, ILogger logger) + { + _serviceProvider = serviceProvider; + _transientEventRepository = transientEventRepository; + _logger = logger; + + CacheSideEffects(events); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var upcaster = _serviceProvider.GetRequiredService(); + await upcaster.WaitForCompletionAsync(); + + _transientEventRepository.NewEvents += HandleNewEvents; + _transientEventRepository.NewTransientEvents += HandleNewTransientEvents; + } + + private async void HandleNewEvents(object? sender, EventArgs eventArgs) + { + await _dispatchLock.WaitAsync(); + + var latestVersion = await _transientEventRepository.GetLatestVersion(); + var newEvents = await _transientEventRepository.GetEvents(_persistedVersion, latestVersion).ToFlatEventList(); + + try + { + await DispatchSideEffects(newEvents); + } + finally + { + _dispatchLock.Release(); + } + + _persistedVersion = latestVersion; + } + + private async void HandleNewTransientEvents(object? sender, EventArgs eventArgs) + { + // We want to fetch the events before hitting the lock to avoid missing events. + var currentEvents = _transientEventRepository.GetCurrentTransientEvents().ToFlatEventList(); + await _dispatchLock.WaitAsync(); + + var newEvents = currentEvents.Where(e => e.Version > _transientVersion); + + try + { + await DispatchSideEffects(newEvents); + if (currentEvents.Any()) + { + _transientVersion = currentEvents.Max(e => e.Version); + } + } + finally + { + _dispatchLock.Release(); + } + } + + private void CacheSideEffects(IEnumerable events) + { + foreach (var sideEffect in events) + { + var eventType = sideEffect.GetType().GetInterface(typeof(SideEffect<>).Name)!.GetGenericArguments()[0]; + if (!_sideEffectCache.ContainsKey(eventType)) + { + _sideEffectCache[eventType] = new List<(SideEffect, MethodInfo)>(); + } + + var method = sideEffect.GetType().GetMethod(nameof(SideEffect.HandleAsync))!; + _sideEffectCache[eventType].Add((sideEffect, method)); + } + } + + private async Task DispatchSideEffects(IEnumerable events) + { + var eventList = events.Where(e => _sideEffectCache.ContainsKey(e.Event.GetType())).ToList(); + + while (eventList.Any()) + { + var userId = eventList.First().By; + var userEvents = eventList.TakeWhile(e => e.By == userId).ToList(); + var unitOfWorkFactory = _serviceProvider.GetUserUnitOfWorkFactory(userId ?? SystemUser.SystemUserGuid); + + foreach (var envelope in userEvents) + { + var type = envelope.Event.GetType(); + var sideEffects = _sideEffectCache[type]; + + foreach (var (sideEffect, handleAsync) in sideEffects) + { + try + { + await unitOfWorkFactory.Commit(async unitOfWork => + { + // For transient events we use the most recent persisted version + long? version = envelope.Event is not TransientEvent ? envelope.Version : null; + var versionedUnitOfWork = unitOfWork.WithPrefilledVersion(version); + + var invocationResult = handleAsync.Invoke(sideEffect, + new object?[] { envelope.Event, versionedUnitOfWork }); + if (invocationResult is not Task> resultTask) + { + throw new Exception( + $"Result of SideEffect {sideEffect.GetType().Name} handler is not a Task>"); + } + + var newEvents = await resultTask; + foreach (var newEvent in newEvents) + { + await unitOfWork.Publish(newEvent); + } + }); + } + catch (Exception e) + { + _logger.LogError(e, ""); + } + } + } + + eventList = eventList.Except(userEvents).ToList(); + } + } + + public Task StopAsync(CancellationToken ct) + { + _transientEventRepository.NewEvents -= HandleNewEvents; + _transientEventRepository.NewTransientEvents -= HandleNewTransientEvents; + + return Task.CompletedTask; + } +} diff --git a/src/Fluss/SideEffects/SideEffectsServiceCollectionExtension.cs b/src/Fluss/SideEffects/SideEffectsServiceCollectionExtension.cs new file mode 100644 index 0000000..40906a2 --- /dev/null +++ b/src/Fluss/SideEffects/SideEffectsServiceCollectionExtension.cs @@ -0,0 +1,21 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace Fluss.SideEffects; + +public static class SideEffectsServiceCollectionExtension +{ + public static IServiceCollection RegisterSideEffects(this IServiceCollection services, Assembly assembly) + { + var implementingClasses = assembly.GetTypes().Where(t => t.IsAssignableTo(typeof(SideEffect))).ToList(); + + foreach (var @class in implementingClasses) + { + services.AddScoped(typeof(SideEffect), @class); + } + + services.AddHostedService(); + + return services; + } +} diff --git a/src/Fluss/UnitOfWork/IUnitOfWork.cs b/src/Fluss/UnitOfWork/IUnitOfWork.cs new file mode 100644 index 0000000..b1808e3 --- /dev/null +++ b/src/Fluss/UnitOfWork/IUnitOfWork.cs @@ -0,0 +1,37 @@ +using Fluss.Aggregates; +using Fluss.Events; +using Fluss.ReadModel; + +namespace Fluss.UnitOfWork; + +// Allows mocking +public interface IUnitOfWork +{ + ValueTask GetAggregate(TKey key) + where TAggregate : AggregateRoot, new(); + + ValueTask Publish(Event @event, AggregateRoot aggregate); + ValueTask ConsistentVersion(); + IReadOnlyCollection ReadModels { get; } + + ValueTask GetReadModel(long? at = null) + where TReadModel : EventListener, IRootEventListener, IReadModel, new(); + + ValueTask GetReadModel(TKey key, long? at = null) + where TReadModel : EventListener, IEventListenerWithKey, IReadModel, new(); + + ValueTask UnsafeGetReadModelWithoutAuthorization(long? at = null) + where TReadModel : EventListener, IRootEventListener, IReadModel, new(); + + ValueTask + UnsafeGetReadModelWithoutAuthorization(TKey key, long? at = null) + where TReadModel : EventListener, IEventListenerWithKey, IReadModel, new(); + + ValueTask> + GetMultipleReadModels(IEnumerable keys, long? at = null) where TKey : notnull + where TReadModel : EventListener, IReadModel, IEventListenerWithKey, new(); + + ValueTask> + UnsafeGetMultipleReadModelsWithoutAuthorization(IEnumerable keys, long? at = null) + where TKey : notnull where TReadModel : EventListener, IReadModel, IEventListenerWithKey, new(); +} diff --git a/src/Fluss/UnitOfWork/UnitOfWork.Aggregates.cs b/src/Fluss/UnitOfWork/UnitOfWork.Aggregates.cs new file mode 100644 index 0000000..d1aa7f0 --- /dev/null +++ b/src/Fluss/UnitOfWork/UnitOfWork.Aggregates.cs @@ -0,0 +1,118 @@ +using System.Collections.Concurrent; +using Fluss.Aggregates; +using Fluss.Events; + +namespace Fluss.UnitOfWork; + +public partial class UnitOfWork +{ + private readonly List _aggregateRoots = new(); + internal readonly ConcurrentQueue PublishedEventEnvelopes = new(); + + public async ValueTask GetAggregate() where TAggregate : AggregateRoot, new() + { + using var activity = FlussActivitySource.Source.StartActivity(); + activity?.SetTag("EventSourcing.Aggregate", typeof(TAggregate).FullName); + + var aggregate = new TAggregate(); + + aggregate = await _eventListenerFactory.UpdateTo(aggregate, await ConsistentVersion()); + + aggregate = aggregate with { UnitOfWork = this }; + + foreach (var publishedEventEnvelope in PublishedEventEnvelopes) + { + aggregate = (TAggregate)aggregate.WhenInt(publishedEventEnvelope); + } + + _aggregateRoots.Add(aggregate); + + return aggregate; + } + + public async ValueTask GetAggregate(TKey key) + where TAggregate : AggregateRoot, new() + { + using var activity = FlussActivitySource.Source.StartActivity(); + activity?.SetTag("EventSourcing.Aggregate", typeof(TAggregate).FullName); + + var aggregate = new TAggregate { Id = key }; + + aggregate = await _eventListenerFactory.UpdateTo(aggregate, await ConsistentVersion()); + + aggregate = aggregate with { UnitOfWork = this }; + + foreach (var publishedEventEnvelope in PublishedEventEnvelopes) + { + aggregate = (TAggregate)aggregate.WhenInt(publishedEventEnvelope); + } + + _aggregateRoots.Add(aggregate); + + return aggregate; + } + + public async ValueTask Publish(Event @event, AggregateRoot? aggregate = null) + { + using var activity = FlussActivitySource.Source.StartActivity(); + + var eventEnvelope = new EventEnvelope + { + At = DateTimeOffset.UtcNow, + By = CurrentUserId(), + Event = @event, + Version = await ConsistentVersion() + PublishedEventEnvelopes.Count + 1 + }; + + if (!await AuthorizeUsage(eventEnvelope)) + { + throw new UnauthorizedAccessException( + $"Cannot add event {eventEnvelope.Event.GetType()} as the current user."); + } + + await ValidateEventResult(eventEnvelope, aggregate); + + PublishedEventEnvelopes.Enqueue(eventEnvelope); + } + + private async ValueTask ValidateEventResult(EventEnvelope envelope, T? aggregate) where T : AggregateRoot + { + if (aggregate is null) return; + + // It's possible that the given aggregate does not have all necessary events applied yet. + aggregate = await UpdateAndApplyPublished(aggregate, null); + + var result = aggregate.WhenInt(envelope) as T; + + if (result == null || result == aggregate) return; + + await _validator.ValidateAggregate(result, this); + } + + internal async ValueTask CommitInternal() + { + using var activity = FlussActivitySource.Source.StartActivity(); + + await Task.WhenAll(PublishedEventEnvelopes.Select(envelope => _validator.ValidateEvent(envelope))); + + await _eventRepository.Publish(PublishedEventEnvelopes); + _consistentVersion += PublishedEventEnvelopes.Count; + PublishedEventEnvelopes.Clear(); + } + + private async ValueTask UpdateAndApplyPublished(TEventListener eventListener, long? at) + where TEventListener : EventListener + { + eventListener = await _eventListenerFactory.UpdateTo(eventListener, at ?? await ConsistentVersion()); + + foreach (var publishedEventEnvelope in PublishedEventEnvelopes) + { + if (eventListener.Tag.LastSeen < publishedEventEnvelope.Version) + { + eventListener = (TEventListener)eventListener.WhenInt(publishedEventEnvelope); + } + } + + return eventListener; + } +} diff --git a/src/Fluss/UnitOfWork/UnitOfWork.Authorization.cs b/src/Fluss/UnitOfWork/UnitOfWork.Authorization.cs new file mode 100644 index 0000000..b42f639 --- /dev/null +++ b/src/Fluss/UnitOfWork/UnitOfWork.Authorization.cs @@ -0,0 +1,43 @@ +using Fluss.Authentication; +using Fluss.Events; +using Fluss.ReadModel; +#if !DEBUG +using Fluss.Extensions; +#endif + +namespace Fluss.UnitOfWork; + +public partial class UnitOfWork +{ + private async ValueTask AuthorizeUsage(EventEnvelope envelope) + { + var ac = new AuthContext(this, CurrentUserId()); +#if DEBUG + var all = +await Task.WhenAll(_policies.Select(async policy => (policy, await policy.AuthenticateEvent(envelope, ac))).ToList()); + + var rejected = all.Where(a => !a.Item2).ToList(); + var accepted = all.Where(a => a.Item2).ToList(); + + return accepted.Count > 0; +#else + return await _policies.Select(p => p.AuthenticateEvent(envelope, ac)).AnyAsync(); +#endif + } + + private async ValueTask AuthorizeUsage(IReadModel eventListener) + { + var ac = new AuthContext(this, CurrentUserId()); +#if DEBUG + var all = +await Task.WhenAll(_policies.Select(async policy => (policy, await policy.AuthenticateReadModel(eventListener, ac))).ToList()); + + var rejected = all.Where(a => !a.Item2).ToList(); + var accepted = all.Where(a => a.Item2).ToList(); + + return accepted.Count > 0; +#else + return await _policies.Select(p => p.AuthenticateReadModel(eventListener, ac)).AnyAsync(); +#endif + } +} diff --git a/src/Fluss/UnitOfWork/UnitOfWork.ReadModels.cs b/src/Fluss/UnitOfWork/UnitOfWork.ReadModels.cs new file mode 100644 index 0000000..12deb3a --- /dev/null +++ b/src/Fluss/UnitOfWork/UnitOfWork.ReadModels.cs @@ -0,0 +1,126 @@ +using System.Collections.Concurrent; +using Fluss.Events; +using Fluss.ReadModel; + +namespace Fluss.UnitOfWork; + +public partial class UnitOfWork +{ + private readonly ConcurrentBag _readModels = new(); + public IReadOnlyCollection ReadModels => _readModels; + + public async ValueTask GetReadModel(long? at = null) + where TReadModel : EventListener, IRootEventListener, IReadModel, new() + { + using var activity = FlussActivitySource.Source.StartActivity(); + activity?.SetTag("EventSourcing.ReadModel", typeof(TReadModel).FullName); + + var readModel = await UpdateAndApplyPublished(new TReadModel(), at); + + if (!await AuthorizeUsage(readModel)) + { + throw new UnauthorizedAccessException($"Cannot read {readModel.GetType()} as the current user."); + } + + if (at is null) + { + _readModels.Add(readModel); + } + + return readModel; + } + + public async ValueTask UnsafeGetReadModelWithoutAuthorization(long? at = null) + where TReadModel : EventListener, IRootEventListener, IReadModel, new() + { + var readModel = await UpdateAndApplyPublished(new TReadModel(), at); + + if (at is null) + { + _readModels.Add(readModel); + } + + return readModel; + } + + public async ValueTask GetReadModel(TKey key, long? at = null) + where TReadModel : EventListener, IEventListenerWithKey, IReadModel, new() + { + using var activity = FlussActivitySource.Source.StartActivity(); + activity?.SetTag("EventSourcing.ReadModel", typeof(TReadModel).FullName); + + var readModel = await UpdateAndApplyPublished(new TReadModel { Id = key }, at); + + if (!await AuthorizeUsage(readModel)) + { + throw new UnauthorizedAccessException($"Cannot read {readModel.GetType()} as the current user."); + } + + if (at is null) + { + _readModels.Add(readModel); + } + + return readModel; + } + + public async ValueTask + UnsafeGetReadModelWithoutAuthorization(TKey key, long? at = null) + where TReadModel : EventListener, IEventListenerWithKey, IReadModel, new() + { + var readModel = await UpdateAndApplyPublished(new TReadModel { Id = key }, at); + + if (at is null) + { + _readModels.Add(readModel); + } + + return readModel; + } + + public async ValueTask> + GetMultipleReadModels(IEnumerable keys, long? at = null) where TKey : notnull + where TReadModel : EventListener, IReadModel, IEventListenerWithKey, new() + { + using var activity = FlussActivitySource.Source.StartActivity(); + activity?.SetTag("EventSourcing.ReadModel", typeof(TReadModel).FullName); + + var dictionary = new ConcurrentDictionary(); + + var keysList = keys.ToList(); + + await Parallel.ForEachAsync(keysList, async (key, _) => + { + var readModel = await UnsafeGetReadModelWithoutAuthorization(key, at); + + if (await AuthorizeUsage(readModel)) + { + dictionary[key] = readModel; + } + else + { + dictionary[key] = null; + } + }); + + return keysList.Select(k => dictionary[k]) + .Where(readModel => readModel != null) + .ToList()!; + } + + public async ValueTask> + UnsafeGetMultipleReadModelsWithoutAuthorization(IEnumerable keys, long? at = null) + where TKey : notnull where TReadModel : EventListener, IReadModel, IEventListenerWithKey, new() + { + var dictionary = new ConcurrentDictionary(); + + var keysList = keys.ToList(); + + await Parallel.ForEachAsync(keysList, async (key, _) => + { + dictionary[key] = await UnsafeGetReadModelWithoutAuthorization(key, at); + }); + + return keysList.Select(k => dictionary[k]).ToList(); + } +} diff --git a/src/Fluss/UnitOfWork/UnitOfWork.cs b/src/Fluss/UnitOfWork/UnitOfWork.cs new file mode 100644 index 0000000..bbcd383 --- /dev/null +++ b/src/Fluss/UnitOfWork/UnitOfWork.cs @@ -0,0 +1,74 @@ +using Fluss.Authentication; +using Fluss.Core.Validation; +using Fluss.Events; + +namespace Fluss.UnitOfWork; + +public partial class UnitOfWork : IUnitOfWork +{ + private readonly IEventListenerFactory _eventListenerFactory; + private readonly IEventRepository _eventRepository; + private readonly IEnumerable _policies; + private readonly IRootValidator _validator; + private readonly UserIdProvider _userIdProvider; + private long? _consistentVersion; + + private Task? _latestVersionLoader; + + public UnitOfWork(IEventRepository eventRepository, IEventListenerFactory eventListenerFactory, + IEnumerable policies, UserIdProvider userIdProvider, IRootValidator validator) + { + using var activity = FlussActivitySource.Source.StartActivity(); + + _eventRepository = eventRepository; + _eventListenerFactory = eventListenerFactory; + _policies = policies; + _userIdProvider = userIdProvider; + _validator = validator; + } + + public async ValueTask ConsistentVersion() + { + if (_consistentVersion.HasValue) + { + return _consistentVersion.Value; + } + + using var activity = FlussActivitySource.Source.StartActivity(); + + lock (this) + { + if (_consistentVersion.HasValue) + { + return _consistentVersion.Value; + } + + _latestVersionLoader ??= Task.Run(async () => + { + var version = await _eventRepository.GetLatestVersion(); + _consistentVersion = version; + return version; + }); + } + + return await _latestVersionLoader; + } + + public UnitOfWork WithPrefilledVersion(long? version) + { + lock (this) + { + if (!_consistentVersion.HasValue && _latestVersionLoader == null) + { + _consistentVersion = version; + } + } + + return this; + } + + private Guid CurrentUserId() + { + return _userIdProvider.Get(); + } +} diff --git a/src/Fluss/UnitOfWork/UnitOfWorkFactory.cs b/src/Fluss/UnitOfWork/UnitOfWorkFactory.cs new file mode 100644 index 0000000..9e35a91 --- /dev/null +++ b/src/Fluss/UnitOfWork/UnitOfWorkFactory.cs @@ -0,0 +1,52 @@ +using Fluss.Exceptions; +using Microsoft.Extensions.DependencyInjection; +using Polly; +using Polly.Contrib.WaitAndRetry; +using Polly.Retry; + +namespace Fluss.UnitOfWork; + +public class UnitOfWorkFactory +{ + private readonly IServiceProvider _serviceProvider; + + public UnitOfWorkFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + private static readonly IList Delay = Backoff + .DecorrelatedJitterBackoffV2(medianFirstRetryDelay: TimeSpan.FromMilliseconds(1), retryCount: 100) + .Select(s => TimeSpan.FromTicks(Math.Min(s.Ticks, TimeSpan.FromSeconds(1).Ticks))).ToList(); + + private static readonly AsyncRetryPolicy RetryPolicy = Policy + .Handle() + .WaitAndRetryAsync(Delay); + + public async ValueTask Commit(Func action) + { + using var activity = FlussActivitySource.Source.StartActivity(); + + await RetryPolicy + .ExecuteAsync(async () => + { + var unitOfWork = _serviceProvider.GetRequiredService(); + await action(unitOfWork); + await unitOfWork.CommitInternal(); + }); + } + + public async ValueTask Commit(Func> action) + { + using var activity = FlussActivitySource.Source.StartActivity(); + + return await RetryPolicy + .ExecuteAsync(async () => + { + var unitOfWork = _serviceProvider.GetRequiredService(); + var result = await action(unitOfWork); + await unitOfWork.CommitInternal(); + return result; + }); + } +} diff --git a/src/Fluss/Upcasting/EventUpcasterService.cs b/src/Fluss/Upcasting/EventUpcasterService.cs new file mode 100644 index 0000000..bad600b --- /dev/null +++ b/src/Fluss/Upcasting/EventUpcasterService.cs @@ -0,0 +1,65 @@ +using Fluss.Events; +using Microsoft.Extensions.Logging; + +namespace Fluss.Upcasting; + +public interface AwaitableService +{ + public Task WaitForCompletionAsync(); +} + +public class EventUpcasterService : AwaitableService +{ + private List _sortedUpcasters; + private IEventRepository _eventRepository; + private ILogger _logger; + + private CancellationTokenSource _onCompletedSource; + + public EventUpcasterService(IEnumerable upcasters, UpcasterSorter sorter, IEventRepository eventRepository, ILogger logger) + { + _sortedUpcasters = sorter.SortByDependencies(upcasters); + _eventRepository = eventRepository; + _logger = logger; + + _onCompletedSource = new CancellationTokenSource(); + } + + public async ValueTask Run() + { + var events = await _eventRepository.GetRawEvents(); + + foreach (var upcaster in _sortedUpcasters) + { + _logger.LogInformation("Running Upcaster {UpcasterName}", upcaster.GetType().Name); + + var upcastedEvents = new List(); + + foreach (var @event in events) + { + var upcastResult = upcaster.Upcast(@event.RawEvent); + if (upcastResult is null) + { + upcastedEvents.Add(@event); + continue; + } + + var envelopes = upcastResult.Select((json, i) => new RawEventEnvelope { RawEvent = json, At = @event.At, By = @event.By, Version = upcastedEvents.Count + i }).ToList(); + await _eventRepository.ReplaceEvent(upcastedEvents.Count, envelopes); + + upcastedEvents.AddRange(envelopes); + } + + events = upcastedEvents; + } + + _onCompletedSource.Cancel(); + } + + public async Task WaitForCompletionAsync() + { + var tcs = new TaskCompletionSource(); + _onCompletedSource.Token.Register(s => ((TaskCompletionSource)s!).SetResult(true), tcs); + await tcs.Task; + } +} diff --git a/src/Fluss/Upcasting/IUpcaster.cs b/src/Fluss/Upcasting/IUpcaster.cs new file mode 100644 index 0000000..0f0b447 --- /dev/null +++ b/src/Fluss/Upcasting/IUpcaster.cs @@ -0,0 +1,17 @@ +using System.Collections.Immutable; +using Newtonsoft.Json.Linq; + +namespace Fluss.Upcasting; + +public interface IUpcaster +{ + public IEnumerable? Upcast(JObject eventJson); +} + +public class DependsOnAttribute : Attribute +{ + public ImmutableHashSet Dependencies { get; private set; } + + public DependsOnAttribute(params Type[] upcasters) => + Dependencies = ImmutableHashSet.Empty.Union(upcasters); +} diff --git a/src/Fluss/Upcasting/UpcasterSorter.cs b/src/Fluss/Upcasting/UpcasterSorter.cs new file mode 100644 index 0000000..a95eb3b --- /dev/null +++ b/src/Fluss/Upcasting/UpcasterSorter.cs @@ -0,0 +1,60 @@ +using System.Reflection; + +namespace Fluss.Upcasting; + +public class UpcasterSortException : Exception +{ + public UpcasterSortException() : base( + $"Failed to sort Upcasters in {UpcasterSorter.MaxIterations} iterations. Ensure the following:\n" + + " - There are no cyclic dependencies\n" + + $" - All dependencies implement {nameof(IUpcaster)}\n" + + " - All upcasters are placed in the same Assembly") + { } +} + +public class UpcasterSorter +{ + internal const int MaxIterations = 100; + + public List SortByDependencies(IEnumerable upcasters) + { + var remaining = upcasters.ToHashSet(); + + var result = remaining.Where(t => GetDependencies(t).Count() == 0).ToList(); + var includedTypes = result.Select(u => u.GetType()).ToHashSet(); + remaining.ExceptWith(result); + + var remainingIterations = MaxIterations; + + while (remaining.Any()) + { + // This approach is not the most performant admittedly but it works :) + var next = remaining.Where(t => GetDependencies(t).All(d => includedTypes.Contains(d))).ToList(); + + remaining.ExceptWith(next); + + result.AddRange(next); + includedTypes.UnionWith(next.Select(u => u.GetType())); + + remainingIterations--; + + if (remainingIterations == 0) + { + throw new UpcasterSortException(); + } + } + + return result; + } + + private static IEnumerable GetDependencies(IUpcaster upcaster) + { + var dependsOn = typeof(DependsOnAttribute); + + var attribute = upcaster.GetType().GetCustomAttribute(dependsOn); + if (attribute is null) return new List(); + + return dependsOn.GetProperty("Dependencies")?.GetValue(attribute) as IEnumerable + ?? throw new ArgumentException("Could not find Dependencies property on DependsOn Attribute!"); + } +} diff --git a/src/Fluss/Validation/AggregateValidator.cs b/src/Fluss/Validation/AggregateValidator.cs new file mode 100644 index 0000000..ceaeb91 --- /dev/null +++ b/src/Fluss/Validation/AggregateValidator.cs @@ -0,0 +1,10 @@ +using Fluss.Aggregates; + +namespace Fluss.Core.Validation; + +public interface AggregateValidator { } + +public interface AggregateValidator : AggregateValidator where T : AggregateRoot +{ + ValueTask ValidateAsync(T aggregateAfterEvent, Fluss.UnitOfWork.UnitOfWork unitOfWorkBeforeEvent); +} diff --git a/src/Fluss/Validation/EventValidator.cs b/src/Fluss/Validation/EventValidator.cs new file mode 100644 index 0000000..d6e3330 --- /dev/null +++ b/src/Fluss/Validation/EventValidator.cs @@ -0,0 +1,10 @@ +using Fluss.Events; + +namespace Fluss.Core.Validation; + +public interface EventValidator { } + +public interface EventValidator : EventValidator where T : Event +{ + ValueTask Validate(T @event, Fluss.UnitOfWork.UnitOfWork unitOfWorkBeforeEvent); +} diff --git a/src/Fluss/Validation/RootValidator.cs b/src/Fluss/Validation/RootValidator.cs new file mode 100644 index 0000000..51751aa --- /dev/null +++ b/src/Fluss/Validation/RootValidator.cs @@ -0,0 +1,83 @@ +using System.Reflection; +using Fluss.Aggregates; +using Fluss.Authentication; +using Fluss.Events; + +namespace Fluss.Core.Validation; + +public interface IRootValidator +{ + public Task ValidateEvent(EventEnvelope envelope); + public Task ValidateAggregate(AggregateRoot aggregate, Fluss.UnitOfWork.UnitOfWork unitOfWork); +} + +public class RootValidator : IRootValidator +{ + private readonly Dictionary> _aggregateValidators = new(); + private readonly Dictionary> _eventValidators = new(); + private readonly IServiceProvider _serviceProvider; + + public RootValidator(IEnumerable aggregateValidators, IEnumerable eventValidators, IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + CacheAggregateValidators(aggregateValidators); + CacheEventValidators(eventValidators); + } + + private void CacheAggregateValidators(IEnumerable validators) + { + foreach (var validator in validators) + { + var aggregateType = validator.GetType().GetInterface(typeof(AggregateValidator<>).Name)!.GetGenericArguments()[0]; + if (!_aggregateValidators.ContainsKey(aggregateType)) + { + _aggregateValidators[aggregateType] = new List<(AggregateValidator, MethodInfo)>(); + } + + var method = validator.GetType().GetMethod(nameof(AggregateValidator.ValidateAsync))!; + _aggregateValidators[aggregateType].Add((validator, method)); + } + } + + private void CacheEventValidators(IEnumerable validators) + { + foreach (var validator in validators) + { + var eventType = validator.GetType().GetInterface(typeof(EventValidator<>).Name)!.GetGenericArguments()[0]; + if (!_eventValidators.ContainsKey(eventType)) + { + _eventValidators[eventType] = new List<(EventValidator, MethodInfo)>(); + } + + var method = validator.GetType().GetMethod(nameof(EventValidator.Validate))!; + _eventValidators[eventType].Add((validator, method)); + } + } + + public async Task ValidateEvent(EventEnvelope envelope) + { + var unitOfWork = _serviceProvider.GetUserUnitOfWork(envelope.By ?? SystemUser.SystemUserGuid); + + var versionedUnitOfWork = unitOfWork.WithPrefilledVersion(envelope.Version - 1); + var type = envelope.Event.GetType(); + + if (!_eventValidators.ContainsKey(type)) return; + + var validators = _eventValidators[type]; + + var invocations = validators.Select(v => v.handler.Invoke(v.validator, new object?[] { envelope.Event, versionedUnitOfWork })); + + await Task.WhenAll(invocations.Cast().Select(async x => await x)); + } + + public async Task ValidateAggregate(AggregateRoot aggregate, Fluss.UnitOfWork.UnitOfWork unitOfWork) + { + var type = aggregate.GetType(); + + if (!_aggregateValidators.TryGetValue(type, out var validator)) return; + + var invocations = validator.Select(v => v.handler.Invoke(v.validator, new object?[] { aggregate, unitOfWork })); + + await Task.WhenAll(invocations.Cast().Select(async x => await x)); + } +} diff --git a/src/Fluss/Validation/ValidationServiceCollectionExtension.cs b/src/Fluss/Validation/ValidationServiceCollectionExtension.cs new file mode 100644 index 0000000..0dfced7 --- /dev/null +++ b/src/Fluss/Validation/ValidationServiceCollectionExtension.cs @@ -0,0 +1,30 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace Fluss.Core.Validation; + +public static class ValidationServiceCollectionExtension +{ + public static IServiceCollection AddValidationFrom(this IServiceCollection services, Assembly sourceAssembly) + { + var aggregateValidatorType = typeof(AggregateValidator); + var aggregateValidators = + sourceAssembly.GetTypes().Where(t => t.IsAssignableTo(aggregateValidatorType)).ToList(); + foreach (var aggregateValidator in aggregateValidators) + { + services.AddScoped(aggregateValidatorType, aggregateValidator); + } + + var eventValidatorType = typeof(EventValidator); + var eventValidators = + sourceAssembly.GetTypes().Where(t => t.IsAssignableTo(eventValidatorType)).ToList(); + foreach (var eventValidator in eventValidators) + { + services.AddScoped(eventValidatorType, eventValidator); + } + + services.AddSingleton(); + + return services; + } +}