From 5fec97a29e411f5b57d4dc621e248a221da8a07c Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 8 Dec 2025 17:03:19 -0500 Subject: [PATCH 1/8] unit --- CQRS.slnx | 12 ++++++ lib/.gitkeep | 0 lib/Logitar.CQRS/LICENSE | 21 ++++++++++ lib/Logitar.CQRS/Logitar.CQRS.csproj | 58 +++++++++++++++++++++++++++ lib/Logitar.CQRS/README.md | 3 ++ lib/Logitar.CQRS/Unit.cs | 51 +++++++++++++++++++++++ lib/Logitar.CQRS/logitar.png | Bin 0 -> 21718 bytes 7 files changed, 145 insertions(+) create mode 100644 CQRS.slnx delete mode 100644 lib/.gitkeep create mode 100644 lib/Logitar.CQRS/LICENSE create mode 100644 lib/Logitar.CQRS/Logitar.CQRS.csproj create mode 100644 lib/Logitar.CQRS/README.md create mode 100644 lib/Logitar.CQRS/Unit.cs create mode 100644 lib/Logitar.CQRS/logitar.png diff --git a/CQRS.slnx b/CQRS.slnx new file mode 100644 index 0000000..f5765d5 --- /dev/null +++ b/CQRS.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/lib/.gitkeep b/lib/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/Logitar.CQRS/LICENSE b/lib/Logitar.CQRS/LICENSE new file mode 100644 index 0000000..70fdfae --- /dev/null +++ b/lib/Logitar.CQRS/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Logitar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/Logitar.CQRS/Logitar.CQRS.csproj b/lib/Logitar.CQRS/Logitar.CQRS.csproj new file mode 100644 index 0000000..22dbc10 --- /dev/null +++ b/lib/Logitar.CQRS/Logitar.CQRS.csproj @@ -0,0 +1,58 @@ + + + + net10.0 + enable + enable + True + Logitar.CQRS + Francis Pion + Logitar.NET + Provides an implementation of the Command Query Responsibility Segregation pattern. + © 2025 Logitar All Rights Reserved. + logitar.png + README.md + git + https://github.com/Logitar/CQRS + 10.0.0.0 + $(AssemblyVersion) + LICENSE + True + 10.0.0 + en-CA + True + Initial release. + logitar net framework cqrs command query responsibility segregation + https://github.com/Logitar/CQRS/tree/main/lib/Logitar.CQRS + + + + True + + + + True + + + + + + + + + + + \ + True + + + \ + True + + + \ + True + + + + diff --git a/lib/Logitar.CQRS/README.md b/lib/Logitar.CQRS/README.md new file mode 100644 index 0000000..94b8c36 --- /dev/null +++ b/lib/Logitar.CQRS/README.md @@ -0,0 +1,3 @@ +# Logitar.CQRS + +Provides an implementation of the Command Query Responsibility Segregation pattern. diff --git a/lib/Logitar.CQRS/Unit.cs b/lib/Logitar.CQRS/Unit.cs new file mode 100644 index 0000000..b3aa077 --- /dev/null +++ b/lib/Logitar.CQRS/Unit.cs @@ -0,0 +1,51 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Logitar.CQRS; + +/// +/// Represents a void type, since is not a valid return type in C#. +/// +public readonly struct Unit +{ + /// + /// Gets the default and only value of a unit. + /// + public static readonly Unit Value = new(); + + /// + /// Gets a completed task from the default and only unit value. + /// + public static Task Task => System.Threading.Tasks.Task.FromResult(Value); + + /// + /// Returns a value indicating whether or not the specified units are equal. + /// + /// The first unit to compare. + /// The other unit to compare. + /// True if the units are equal. + public static bool operator ==(Unit left, Unit right) => left.Equals(right); + /// + /// Returns a value indicating whether or not the specified units are different. + /// + /// The first unit to compare. + /// The other unit to compare. + /// True if the units are different. + public static bool operator !=(Unit left, Unit right) => !(left == right); + + /// + /// Returns a value indicating whether or not the specified object is equal to the unit. + /// + /// The object to be compared to. + /// True if the object is equal to the unit. + public override bool Equals([NotNullWhen(true)] object? obj) => obj is Unit; + /// + /// Returns the hash code of the current unit. + /// + /// The hash code. + public override int GetHashCode() => 0; + /// + /// Returns a string representation of the unit. + /// + /// The string representation. + public override string ToString() => "()"; +} diff --git a/lib/Logitar.CQRS/logitar.png b/lib/Logitar.CQRS/logitar.png new file mode 100644 index 0000000000000000000000000000000000000000..5b031f488834788deaef9120d1a5695503601c98 GIT binary patch literal 21718 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 literal 0 HcmV?d00001 From 76121b53e050c1ccf985545621bd0572543376d9 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 8 Dec 2025 17:03:48 -0500 Subject: [PATCH 2/8] command_query --- lib/Logitar.CQRS/ICommand.cs | 12 ++++++++++++ lib/Logitar.CQRS/ICommandHandler.cs | 17 +++++++++++++++++ lib/Logitar.CQRS/IQuery.cs | 7 +++++++ lib/Logitar.CQRS/IQueryHandler.cs | 17 +++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 lib/Logitar.CQRS/ICommand.cs create mode 100644 lib/Logitar.CQRS/ICommandHandler.cs create mode 100644 lib/Logitar.CQRS/IQuery.cs create mode 100644 lib/Logitar.CQRS/IQueryHandler.cs diff --git a/lib/Logitar.CQRS/ICommand.cs b/lib/Logitar.CQRS/ICommand.cs new file mode 100644 index 0000000..62b4369 --- /dev/null +++ b/lib/Logitar.CQRS/ICommand.cs @@ -0,0 +1,12 @@ +namespace Logitar.CQRS; + +/// +/// Represents a command without result. +/// +public interface ICommand : ICommand; + +/// +/// Represents a command returning a result. +/// +/// The type of the result. +public interface ICommand; diff --git a/lib/Logitar.CQRS/ICommandHandler.cs b/lib/Logitar.CQRS/ICommandHandler.cs new file mode 100644 index 0000000..e4abeff --- /dev/null +++ b/lib/Logitar.CQRS/ICommandHandler.cs @@ -0,0 +1,17 @@ +namespace Logitar.CQRS; + +/// +/// Represents a handler for a specific command. +/// +/// The type of the command. +/// The type of the command result. +public interface ICommandHandler where TCommand : ICommand +{ + /// + /// Handles the specified command and returns its result. + /// + /// The command to handle. + /// The cancellation token. + /// The command result. + Task HandleAsync(TCommand command, CancellationToken cancellationToken = default); +} diff --git a/lib/Logitar.CQRS/IQuery.cs b/lib/Logitar.CQRS/IQuery.cs new file mode 100644 index 0000000..d10a1f4 --- /dev/null +++ b/lib/Logitar.CQRS/IQuery.cs @@ -0,0 +1,7 @@ +namespace Logitar.CQRS; + +/// +/// Represents a query returning a result. +/// +/// The type of the result. +public interface IQuery; diff --git a/lib/Logitar.CQRS/IQueryHandler.cs b/lib/Logitar.CQRS/IQueryHandler.cs new file mode 100644 index 0000000..cfcdec6 --- /dev/null +++ b/lib/Logitar.CQRS/IQueryHandler.cs @@ -0,0 +1,17 @@ +namespace Logitar.CQRS; + +/// +/// Represents a handler for a specific query. +/// +/// The type of the query. +/// The type of the query result. +public interface IQueryHandler where TQuery : IQuery +{ + /// + /// Handles the specified query and returns its result. + /// + /// The query to handle. + /// The cancellation token. + /// The query result. + Task HandleAsync(TQuery query, CancellationToken cancellationToken = default); +} From c56e988376e28735b85ed8cb8256ed2676ed35be Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 8 Dec 2025 19:26:30 -0500 Subject: [PATCH 3/8] interfaces --- lib/Logitar.CQRS/ICommandBus.cs | 16 ++++++++++++++++ lib/Logitar.CQRS/IQueryBus.cs | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 lib/Logitar.CQRS/ICommandBus.cs create mode 100644 lib/Logitar.CQRS/IQueryBus.cs diff --git a/lib/Logitar.CQRS/ICommandBus.cs b/lib/Logitar.CQRS/ICommandBus.cs new file mode 100644 index 0000000..1b97aaf --- /dev/null +++ b/lib/Logitar.CQRS/ICommandBus.cs @@ -0,0 +1,16 @@ +namespace Logitar.CQRS; + +/// +/// Represents a bus in which commands are sent. +/// +public interface ICommandBus +{ + /// + /// Executes the specified command. + /// + /// The type of the command result. + /// The command to execute. + /// The cancellation token. + /// The command result. + Task ExecuteAsync(ICommand command, CancellationToken cancellationToken = default); +} diff --git a/lib/Logitar.CQRS/IQueryBus.cs b/lib/Logitar.CQRS/IQueryBus.cs new file mode 100644 index 0000000..715f52b --- /dev/null +++ b/lib/Logitar.CQRS/IQueryBus.cs @@ -0,0 +1,16 @@ +namespace Logitar.CQRS; + +/// +/// Represents a bus in which queries are sent. +/// +public interface IQueryBus +{ + /// + /// Executes the specified query. + /// + /// The type of the query result. + /// The query to execute. + /// The cancellation token. + /// The query result. + Task ExecuteAsync(IQuery query, CancellationToken cancellationToken = default); +} From b0c526b117fc33459ec8355638b0aaa49d3d22bd Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 8 Dec 2025 20:37:42 -0500 Subject: [PATCH 4/8] Unit --- lib/Logitar.CQRS/Unit.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Logitar.CQRS/Unit.cs b/lib/Logitar.CQRS/Unit.cs index b3aa077..474445c 100644 --- a/lib/Logitar.CQRS/Unit.cs +++ b/lib/Logitar.CQRS/Unit.cs @@ -15,7 +15,7 @@ public readonly struct Unit /// /// Gets a completed task from the default and only unit value. /// - public static Task Task => System.Threading.Tasks.Task.FromResult(Value); + public static Task CompletedTask => Task.FromResult(Value); /// /// Returns a value indicating whether or not the specified units are equal. From 34d59cf3484d17bc5c2eb4b22506e19f4822b06f Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 8 Dec 2025 20:37:51 -0500 Subject: [PATCH 5/8] settings --- lib/Logitar.CQRS/RetryAlgorithm.cs | 36 ++++++++++++++++++ lib/Logitar.CQRS/RetrySettings.cs | 60 ++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 lib/Logitar.CQRS/RetryAlgorithm.cs create mode 100644 lib/Logitar.CQRS/RetrySettings.cs diff --git a/lib/Logitar.CQRS/RetryAlgorithm.cs b/lib/Logitar.CQRS/RetryAlgorithm.cs new file mode 100644 index 0000000..4d92d11 --- /dev/null +++ b/lib/Logitar.CQRS/RetryAlgorithm.cs @@ -0,0 +1,36 @@ +namespace Logitar.CQRS; + +/// +/// Defines retry scheduling strategies used to determine when subsequent attempts are executed. +/// +public enum RetryAlgorithm +{ + /// + /// No retry is performed. The operation fails immediately on the first error. + /// + None = 0, + + /// + /// Each retry delay increases exponentially, typically doubling after every failed attempt + /// (e.g., 1s, 2s, 4s, 8s). Useful for reducing load on congested systems. + /// + Exponential = 1, + + /// + /// A constant delay is applied between every retry attempt (e.g., always 5s). + /// Suitable for predictable and steady retry pacing. + /// + Fixed = 2, + + /// + /// The delay grows linearly with each retry attempt (e.g., 1s, 2s, 3s, 4s). + /// Provides gradual backoff without the rapid escalation of exponential strategies. + /// + Linear = 3, + + /// + /// Each retry delay is selected randomly within a defined range. + /// Helps reduce synchronized retry storms across multiple clients. + /// + Random = 4 +} diff --git a/lib/Logitar.CQRS/RetrySettings.cs b/lib/Logitar.CQRS/RetrySettings.cs new file mode 100644 index 0000000..e0307e2 --- /dev/null +++ b/lib/Logitar.CQRS/RetrySettings.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Configuration; + +namespace Logitar.CQRS; + +/// +/// Represents configuration options for retry behaviour, including timing strategy, base delays, and operational limits. +/// +public record RetrySettings +{ + /// + /// The configuration section key used to bind retry settings. + /// + public const string SectionKey = "Retry"; + + /// + /// The algorithm determining how retry delays are calculated. + /// + public RetryAlgorithm Algorithm { get; set; } + /// + /// The base delay, in milliseconds, applied before the first retry or used as the fixed interval depending on the algorithm. + /// + public int Delay { get; set; } + /// + /// The numeric base used when applying exponential backoff. For example, a base of 2 doubles the delay on each retry attempt. + /// + public int ExponentialBase { get; set; } + /// + /// The maximum random variation, in milliseconds, added or subtracted when using randomised retry delays. + /// + public int RandomVariation { get; set; } + + /// + /// The maximum number of retry attempts allowed before the operation is considered failed. A value of 0 typically means no retries. + /// + public int MaximumRetries { get; set; } + /// + /// The maximum delay, in milliseconds, permitted between retry attempts. This acts as a safety cap for exponential or linear backoff strategies. + /// + public int MaximumDelay { get; set; } + + /// + /// Initializes by binding the configuration section and applying environment variable overrides when present. + /// + /// The configuration. + /// The initialized settings. + public static RetrySettings Initialize(IConfiguration configuration) + { + RetrySettings settings = configuration.GetSection(SectionKey).Get() ?? new(); + + settings.Algorithm = EnvironmentHelper.GetEnum("RETRY_ALGORITHM", settings.Algorithm); + settings.Delay = EnvironmentHelper.GetInt32("RETRY_DELAY", settings.Delay); + settings.ExponentialBase = EnvironmentHelper.GetInt32("RETRY_EXPONENTIAL_BASE", settings.ExponentialBase); + settings.RandomVariation = EnvironmentHelper.GetInt32("RETRY_RANDOM_VARIATION", settings.RandomVariation); + + settings.MaximumRetries = EnvironmentHelper.GetInt32("RETRY_MAXIMUM_RETRIES", settings.MaximumRetries); + settings.MaximumDelay = EnvironmentHelper.GetInt32("RETRY_MAXIMUM_DELAY", settings.MaximumDelay); + + return settings; + } +} From 63ce55d26cac610c95dae0e8164808f5c26cb8c7 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 8 Dec 2025 23:07:04 -0500 Subject: [PATCH 6/8] CommandBus --- CQRS.slnx | 3 + lib/Logitar.CQRS/CommandBus.cs | 195 ++++++++ .../DependencyInjectionExtensions.cs | 24 + lib/Logitar.CQRS/Logitar.CQRS.csproj | 7 + lib/Logitar.CQRS/RetrySettings.cs | 79 ++++ lib/Logitar.CQRS/Unit.cs | 4 +- tests/Logitar.CQRS.Tests/Categories.cs | 6 + tests/Logitar.CQRS.Tests/Command.cs | 3 + tests/Logitar.CQRS.Tests/CommandBusTests.cs | 442 ++++++++++++++++++ tests/Logitar.CQRS.Tests/CommandHandler.cs | 6 + tests/Logitar.CQRS.Tests/FakeCommandBus.cs | 27 ++ .../HandlelessCommandHandler.cs | 3 + tests/Logitar.CQRS.Tests/InvalidCommandBus.cs | 23 + .../InvalidReturnCommandHandler.cs | 6 + .../Logitar.CQRS.Tests.csproj | 38 ++ .../NotImplementedCommandHandler.cs | 9 + tests/Logitar.CQRS.Tests/Traits.cs | 6 + 17 files changed, 878 insertions(+), 3 deletions(-) create mode 100644 lib/Logitar.CQRS/CommandBus.cs create mode 100644 lib/Logitar.CQRS/DependencyInjectionExtensions.cs create mode 100644 tests/Logitar.CQRS.Tests/Categories.cs create mode 100644 tests/Logitar.CQRS.Tests/Command.cs create mode 100644 tests/Logitar.CQRS.Tests/CommandBusTests.cs create mode 100644 tests/Logitar.CQRS.Tests/CommandHandler.cs create mode 100644 tests/Logitar.CQRS.Tests/FakeCommandBus.cs create mode 100644 tests/Logitar.CQRS.Tests/HandlelessCommandHandler.cs create mode 100644 tests/Logitar.CQRS.Tests/InvalidCommandBus.cs create mode 100644 tests/Logitar.CQRS.Tests/InvalidReturnCommandHandler.cs create mode 100644 tests/Logitar.CQRS.Tests/Logitar.CQRS.Tests.csproj create mode 100644 tests/Logitar.CQRS.Tests/NotImplementedCommandHandler.cs create mode 100644 tests/Logitar.CQRS.Tests/Traits.cs diff --git a/CQRS.slnx b/CQRS.slnx index f5765d5..b4c46ba 100644 --- a/CQRS.slnx +++ b/CQRS.slnx @@ -8,5 +8,8 @@ + + + diff --git a/lib/Logitar.CQRS/CommandBus.cs b/lib/Logitar.CQRS/CommandBus.cs new file mode 100644 index 0000000..98166a7 --- /dev/null +++ b/lib/Logitar.CQRS/CommandBus.cs @@ -0,0 +1,195 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Logitar.CQRS; + +/// +/// Represents an in-memory bus in which sent commands are executed synchronously. +/// +public class CommandBus : ICommandBus +{ + /// + /// The name of the command handler method. + /// + protected const string HandlerName = nameof(ICommandHandler<,>.HandleAsync); + + /// + /// Gets the logger. + /// + protected virtual ILogger? Logger { get; } + /// + /// Gets the pseudo-random number generator. + /// + protected virtual Random Random { get; } = new(); + /// + /// Gets the service provider. + /// + protected virtual IServiceProvider ServiceProvider { get; } + /// + /// Gets the retry settings. + /// + protected virtual RetrySettings Settings { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The service provider. + public CommandBus(IServiceProvider serviceProvider) + { + Logger = serviceProvider.GetService>(); + ServiceProvider = serviceProvider; + Settings = serviceProvider.GetService() ?? new(); + } + + /// + /// Executes the specified command. + /// + /// The type of the command result. + /// The command to execute. + /// The cancellation token. + /// The command result. + /// The handler did not define the handle method, or it did not return a task. + public virtual async Task ExecuteAsync(ICommand command, CancellationToken cancellationToken) + { + Settings.Validate(); + + object handler = await GetHandlerAsync(command, cancellationToken); + + Type handlerType = handler.GetType(); + Type commandType = command.GetType(); + Type[] parameterTypes = [commandType, typeof(CancellationToken)]; + MethodInfo handle = handlerType.GetMethod(HandlerName, parameterTypes) + ?? throw new InvalidOperationException($"The handler {handlerType} must define a '{HandlerName}' method."); + + object[] parameters = [command, cancellationToken]; + Exception? innerException; + int attempt = 0; + while (true) + { + attempt++; + try + { + object? result = handle.Invoke(handler, parameters); + if (result is not Task task) + { + throw new InvalidOperationException($"The handler {handlerType} {HandlerName} method must return a {nameof(Task)}."); + } + return await task; + } + catch (Exception exception) + { + if (!ShouldRetry(command, exception)) + { + throw; + } + innerException = exception; + + int millisecondsDelay = CalculateMillisecondsDelay(command, exception, attempt); + if (millisecondsDelay < 0) + { + throw new InvalidOperationException($"The retry delay '{millisecondsDelay}' should be greater than or equal to 0ms."); + } + + if (Settings.Algorithm == RetryAlgorithm.None + || (Settings.MaximumRetries > 0 && attempt > Settings.MaximumRetries) + || (Settings.MaximumDelay > 0 && millisecondsDelay > Settings.MaximumDelay)) + { + break; + } + + if (Logger is not null && Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning(exception, "Command '{Command}' execution failed at attempt {Attempt}, will retry in {Delay}ms.", commandType, attempt, millisecondsDelay); + } + + if (millisecondsDelay > 0) + { + await Task.Delay(millisecondsDelay, cancellationToken); + } + } + } + + throw new InvalidOperationException($"Command '{commandType}' execution failed after {attempt} attempts. See inner exception for more detail.", innerException); + } + + /// + /// Finds the handler for the specified command. + /// + /// The type of the command result. + /// The command. + /// The cancellation token. + /// The command handler. + /// There is no handler or many handlers for the specified command. + protected virtual async Task GetHandlerAsync(ICommand command, CancellationToken cancellationToken) + { + Type commandType = command.GetType(); + IEnumerable handlers = ServiceProvider.GetServices(typeof(ICommandHandler<,>).MakeGenericType(commandType, typeof(TResult))) + .Where(handler => handler is not null) + .Select(handler => handler!); + int count = handlers.Count(); + if (count != 1) + { + StringBuilder message = new StringBuilder("Exactly one handler was expected for command of type '").Append(commandType).Append("', but "); + if (count < 1) + { + message.Append("none was found."); + } + else + { + message.Append(count).Append(" were found."); + } + throw new InvalidOperationException(message.ToString()); + } + return handlers.Single(); + } + + /// + /// Determines if the command execution should be retried or not. + /// + /// The type of the command result. + /// The command to execute. + /// The exception. + /// A value indicating whether or not the command execution should be retried. + protected virtual bool ShouldRetry(ICommand command, Exception exception) + { + return true; + } + + /// + /// Calculates the delay, in milliseconds, to wait before retrying the execution of a command after a failure. + /// The delay is computed according to the retry algorithm and configuration defined in . + /// + /// The type of the command result. + /// The command to execute. + /// The exception. + /// The current retry attempt number, starting at 1. + /// The number of milliseconds to wait before retrying. Returns 0 when retrying should occur immediately or when the configured delay or algorithm does not produce a positive value. + protected virtual int CalculateMillisecondsDelay(ICommand command, Exception exception, int attempt) + { + if (Settings.Delay > 0) + { + switch (Settings.Algorithm) + { + case RetryAlgorithm.Exponential: + if (Settings.ExponentialBase > 1) + { + return (int)Math.Pow(Settings.ExponentialBase, attempt - 1) * Settings.Delay; + } + break; + case RetryAlgorithm.Fixed: + return Settings.Delay; + case RetryAlgorithm.Linear: + return attempt * Settings.Delay; + case RetryAlgorithm.Random: + if (Settings.RandomVariation > 0 && Settings.RandomVariation < Settings.Delay) + { + int minimum = Settings.Delay - Settings.RandomVariation; + int maximum = Settings.Delay + Settings.RandomVariation; + return Random.Next(minimum, maximum + 1); + } + break; + } + } + return 0; + } +} diff --git a/lib/Logitar.CQRS/DependencyInjectionExtensions.cs b/lib/Logitar.CQRS/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..ccbd448 --- /dev/null +++ b/lib/Logitar.CQRS/DependencyInjectionExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Logitar.CQRS; + +/// +/// Provides extension methods for registering the Logitar CQRS pattern into a dependency injection container. +/// +public static class DependencyInjectionExtensions +{ + /// + /// Registers the command and query buses, along with their supporting configuration such as , into the service collection. + /// This enables Logitar's CQRS handling throughout the application. + /// + /// The service collection. + /// The service collection. + public static IServiceCollection AddLogitarCQRS(this IServiceCollection services) + { + return services + .AddSingleton(serviceProvider => RetrySettings.Initialize(serviceProvider.GetRequiredService())) + .AddTransient() + .AddTransient(); + } +} diff --git a/lib/Logitar.CQRS/Logitar.CQRS.csproj b/lib/Logitar.CQRS/Logitar.CQRS.csproj index 22dbc10..03c0236 100644 --- a/lib/Logitar.CQRS/Logitar.CQRS.csproj +++ b/lib/Logitar.CQRS/Logitar.CQRS.csproj @@ -38,6 +38,7 @@ + @@ -55,4 +56,10 @@ + + + + + + diff --git a/lib/Logitar.CQRS/RetrySettings.cs b/lib/Logitar.CQRS/RetrySettings.cs index e0307e2..01d7c5d 100644 --- a/lib/Logitar.CQRS/RetrySettings.cs +++ b/lib/Logitar.CQRS/RetrySettings.cs @@ -57,4 +57,83 @@ public static RetrySettings Initialize(IConfiguration configuration) return settings; } + + /// + /// Validates the retry settings. + /// + public void Validate() + { + List errors = new(capacity: 7); + + if (Delay < 0) + { + errors.Add($"'{nameof(Delay)}' must be greater than or equal to 0."); + } + if (MaximumDelay < 0) + { + errors.Add($"'{nameof(MaximumDelay)}' must be greater than or equal to 0."); + } + + switch (Algorithm) + { + case RetryAlgorithm.Exponential: + if (Delay <= 0) + { + errors.Add($"'{nameof(Delay)}' must be greater than 0."); + } + if (ExponentialBase <= 1) + { + errors.Add($"'{nameof(ExponentialBase)}' must be greater than 1."); + } + break; + case RetryAlgorithm.Linear: + if (Delay <= 0) + { + errors.Add($"'{nameof(Delay)}' must be greater than 0."); + } + if (MaximumDelay > 0) + { + errors.Add($"'{nameof(Delay)}' must be 0 when '{nameof(Algorithm)}' is {Algorithm}."); + } + break; + case RetryAlgorithm.Random: + if (Delay <= 0) + { + errors.Add($"'{nameof(Delay)}' must be greater than 0."); + } + if (RandomVariation <= 0) + { + errors.Add($"'{nameof(RandomVariation)}' must be greater than 0."); + } + if (RandomVariation > Delay) + { + errors.Add($"'{nameof(RandomVariation)}' must be less than or equal to '{nameof(Delay)}'."); + } + if (MaximumDelay > 0) + { + errors.Add($"'{nameof(Delay)}' must be 0 when '{nameof(Algorithm)}' is {Algorithm}."); + } + break; + case RetryAlgorithm.Fixed: + case RetryAlgorithm.None: + break; + default: + throw new ArgumentOutOfRangeException(nameof(Algorithm)); + } + + if (MaximumRetries < 0) + { + errors.Add($"'{nameof(MaximumRetries)}' must be greater than or equal to 0."); + } + + if (errors.Count > 0) + { + StringBuilder message = new("Validation failed."); + foreach (string error in errors) + { + message.AppendLine().Append(" - ").Append(error); + } + throw new InvalidOperationException(message.ToString()); + } + } } diff --git a/lib/Logitar.CQRS/Unit.cs b/lib/Logitar.CQRS/Unit.cs index 474445c..37bebd0 100644 --- a/lib/Logitar.CQRS/Unit.cs +++ b/lib/Logitar.CQRS/Unit.cs @@ -1,6 +1,4 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Logitar.CQRS; +namespace Logitar.CQRS; /// /// Represents a void type, since is not a valid return type in C#. diff --git a/tests/Logitar.CQRS.Tests/Categories.cs b/tests/Logitar.CQRS.Tests/Categories.cs new file mode 100644 index 0000000..81f6887 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/Categories.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal static class Categories +{ + public const string Unit = "Unit"; +} diff --git a/tests/Logitar.CQRS.Tests/Command.cs b/tests/Logitar.CQRS.Tests/Command.cs new file mode 100644 index 0000000..64f3c63 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/Command.cs @@ -0,0 +1,3 @@ +namespace Logitar.CQRS.Tests; + +public record Command : ICommand; diff --git a/tests/Logitar.CQRS.Tests/CommandBusTests.cs b/tests/Logitar.CQRS.Tests/CommandBusTests.cs new file mode 100644 index 0000000..c616e36 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/CommandBusTests.cs @@ -0,0 +1,442 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Logitar.CQRS.Tests; + +[Trait(Traits.Category, Categories.Unit)] +public class CommandBusTests +{ + private readonly CancellationToken _cancellationToken = default; + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the algorithm is None.")] + public void Given_AlgorithmIsNone_When_CalculateMillisecondsDelay_Then_ZeroReturned() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.None, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the delay is zero or negative.")] + [InlineData(0)] + [InlineData(-1)] + public void Given_ZeroOrNegativeDelay_When_CalculateMillisecondsDelay_Then_ZeroReturned(int delay) + { + Assert.True(delay <= 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Fixed, + Delay = delay + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the exponential base is less than 2.")] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + public void Given_ExponentialBaseLessThanTwo_When_CalculateMillisecondsDelay_Then_ZeroReturned(int exponentialBase) + { + Assert.True(exponentialBase < 2); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Exponential, + Delay = 100, + ExponentialBase = exponentialBase + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the random variation is greater than or equal to the delay.")] + [InlineData(100, 100)] + [InlineData(100, 1000)] + public void Given_RandomVariationGreaterThanOrEqualToDelay_When_CalculateMillisecondsDelay_Then_ZeroReturned(int delay, int randomVariation) + { + Assert.True(delay > 0); + Assert.True(randomVariation > 0); + Assert.True(delay <= randomVariation); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 100, + RandomVariation = randomVariation + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the random variation is zero or negative.")] + [InlineData(0)] + [InlineData(-1)] + public void Given_ZeroOrNegativeRandomVariation_When_CalculateMillisecondsDelay_Then_ZeroReturned(int randomVariation) + { + Assert.True(randomVariation <= 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 100, + RandomVariation = randomVariation + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return the correct exponential delay.")] + [InlineData(100, 10, 2, 1000)] + [InlineData(100, 2, 5, 1600)] + public void Given_Exponential_When_CalculateMillisecondsDelay_Then_CorrectDelay(int delay, int exponentialBase, int attempt, int millisecondsDelay) + { + Assert.True(delay > 0); + Assert.True(exponentialBase > 1); + Assert.True(attempt > 0); + Assert.True(millisecondsDelay > 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Exponential, + Delay = delay, + ExponentialBase = exponentialBase + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + Assert.Equal(millisecondsDelay, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct fixed delay.")] + public void Given_Fixed_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 10; + Assert.Equal(settings.Delay, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct linear delay.")] + public void Given_Linear_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Linear, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 5; + Assert.Equal(settings.Delay * attempt, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct random delay.")] + public void Given_Random_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 500, + RandomVariation = 450 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 5; + int delay = commandBus.CalculateMillisecondsDelay(command, exception, attempt); + + int minimum = settings.Delay - settings.RandomVariation; + int maximum = settings.Delay + settings.RandomVariation; + Assert.True(minimum <= delay && delay <= maximum); + } + + [Fact(DisplayName = "ExecuteAsync: it should log a warning when an execution is retried.")] + public async Task Given_Retry_When_ExecuteAsync_Then_WarningLogged() + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedCommandHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100, + MaximumRetries = 2 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + CommandBus commandBus = new(serviceProvider); + + Command command = new(); + await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.AtLeastOnce()); + } + + [Fact(DisplayName = "ExecuteAsync: it should rethrow the exception when it should not be retried.")] + public async Task Given_ExceptionNotRetried_When_ExecuteAsync_Then_ExceptionRethrown() + { + ServiceCollection services = new(); + services.AddSingleton, NotImplementedCommandHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + Assert.IsType(exception.InnerException); + } + + [Fact(DisplayName = "ExecuteAsync: it should retry the execution given a maximum delay.")] + public async Task Given_MaximumDelay_When_ExecuteAsync_Then_RetriedUntilReached() + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedCommandHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Exponential, + Delay = 100, + ExponentialBase = 2, + MaximumDelay = 1000 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + CommandBus commandBus = new(serviceProvider); + + Command command = new(); + await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.Exactly(4)); // NOTE(fpion): 100, 200, 400, 800 + } + + [Theory(DisplayName = "ExecuteAsync: it should retry the execution given maximum retries.")] + [InlineData(1)] + [InlineData(5)] + public async Task Given_MaximumRetries_When_ExecuteAsync_Then_RetriedUntilReached(int maximumRetries) + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedCommandHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100, + MaximumRetries = maximumRetries + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + CommandBus commandBus = new(serviceProvider); + + Command command = new(); + await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.Exactly(maximumRetries)); + } + + [Fact(DisplayName = "ExecuteAsync: it should return the execution result when it succeeded.")] + public async Task Given_Success_When_ExecuteAsync_Then_ExecutionResult() + { + ServiceCollection services = new(); + services.AddSingleton, CommandHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Unit result = await commandBus.ExecuteAsync(command, _cancellationToken); + Assert.Equal(Unit.Value, result); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the execution failed.")] + public async Task Given_ExecutionFailed_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton, NotImplementedCommandHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + CommandBus commandBus = new(serviceProvider); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + Assert.Equal($"Command '{command.GetType()}' execution failed after 1 attempts. See inner exception for more detail.", exception.Message); + Assert.IsType(exception.InnerException); + Assert.IsType(exception.InnerException.InnerException); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the handler does not have a handle.")] + public async Task Given_HandlerWithoutHandle_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidCommandBus commandBus = new(serviceProvider, new HandlelessCommandHandler()); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + Assert.Equal($"The handler {typeof(HandlelessCommandHandler)} must define a 'HandleAsync' method.", exception.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the handler does not return a task.")] + public async Task Given_HandlerDoesNotReturnTask_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidCommandBus commandBus = new(serviceProvider, new InvalidReturnCommandHandler()); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + Assert.IsType(exception.InnerException); + Assert.Equal($"The handler {typeof(InvalidReturnCommandHandler)} HandleAsync method must return a Task.", exception.InnerException.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the milliseconds delay is negative.")] + public async Task Given_NegativeDelay_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidCommandBus commandBus = new(serviceProvider, new NotImplementedCommandHandler(), millisecondsDelay: -1); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + Assert.Equal("The retry delay '-1' should be greater than or equal to 0ms.", exception.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the settings are not valid.")] + public async Task Given_InvalidSettings_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton(new RetrySettings + { + Delay = -1 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + CommandBus commandBus = new(serviceProvider); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than or equal to 0.", lines[1]); + } + + [Fact(DisplayName = "GetHandlerAsync: it should return the handler found.")] + public async Task Given_SingleHandler_When_GetHandlerAsync_Then_HandlerReturned() + { + ServiceCollection services = new(); + services.AddSingleton, CommandHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + object handler = await commandBus.GetHandlerAsync(command, _cancellationToken); + Assert.IsType(handler); + } + + [Fact(DisplayName = "GetHandlerAsync: it should throw InvalidOperationException when many handlers were found.")] + public async Task Given_ManyHandlers_When_GetHandlerAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton, CommandHandler>(); + services.AddSingleton, CommandHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.GetHandlerAsync(command, _cancellationToken)); + Assert.Equal($"Exactly one handler was expected for command of type '{command.GetType()}', but 2 were found.", exception.Message); + } + + [Fact(DisplayName = "GetHandlerAsync: it should throw InvalidOperationException when no handler was found.")] + public async Task Given_NoHandler_When_GetHandlerAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.GetHandlerAsync(command, _cancellationToken)); + Assert.Equal($"Exactly one handler was expected for command of type '{command.GetType()}', but none was found.", exception.Message); + } +} diff --git a/tests/Logitar.CQRS.Tests/CommandHandler.cs b/tests/Logitar.CQRS.Tests/CommandHandler.cs new file mode 100644 index 0000000..07f42b2 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/CommandHandler.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal class CommandHandler : ICommandHandler +{ + public Task HandleAsync(Command command, CancellationToken cancellationToken) => Unit.CompletedTask; +} diff --git a/tests/Logitar.CQRS.Tests/FakeCommandBus.cs b/tests/Logitar.CQRS.Tests/FakeCommandBus.cs new file mode 100644 index 0000000..8060f9d --- /dev/null +++ b/tests/Logitar.CQRS.Tests/FakeCommandBus.cs @@ -0,0 +1,27 @@ +namespace Logitar.CQRS.Tests; + +internal class FakeCommandBus : CommandBus +{ + public FakeCommandBus(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + protected override bool ShouldRetry(ICommand command, Exception exception) + { + if (exception is TargetInvocationException targetInvocation && targetInvocation.InnerException is not null) + { + exception = targetInvocation.InnerException; + } + return exception is not NotImplementedException; + } + + public new async Task GetHandlerAsync(ICommand command, CancellationToken cancellationToken) + { + return await base.GetHandlerAsync(command, cancellationToken); + } + + public new int CalculateMillisecondsDelay(ICommand command, Exception exception, int attempt) + { + return base.CalculateMillisecondsDelay(command, exception, attempt); + } +} diff --git a/tests/Logitar.CQRS.Tests/HandlelessCommandHandler.cs b/tests/Logitar.CQRS.Tests/HandlelessCommandHandler.cs new file mode 100644 index 0000000..3a8112e --- /dev/null +++ b/tests/Logitar.CQRS.Tests/HandlelessCommandHandler.cs @@ -0,0 +1,3 @@ +namespace Logitar.CQRS.Tests; + +internal class HandlelessCommandHandler; diff --git a/tests/Logitar.CQRS.Tests/InvalidCommandBus.cs b/tests/Logitar.CQRS.Tests/InvalidCommandBus.cs new file mode 100644 index 0000000..163faf8 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/InvalidCommandBus.cs @@ -0,0 +1,23 @@ +namespace Logitar.CQRS.Tests; + +internal class InvalidCommandBus : CommandBus +{ + private readonly object _handler; + private readonly int _millisecondsDelay; + + public InvalidCommandBus(IServiceProvider serviceProvider, object handler, int millisecondsDelay = 0) : base(serviceProvider) + { + _handler = handler; + _millisecondsDelay = millisecondsDelay; + } + + protected override Task GetHandlerAsync(ICommand command, CancellationToken cancellationToken) + { + return Task.FromResult(_handler); + } + + protected override int CalculateMillisecondsDelay(ICommand command, Exception exception, int attempt) + { + return _millisecondsDelay; + } +} diff --git a/tests/Logitar.CQRS.Tests/InvalidReturnCommandHandler.cs b/tests/Logitar.CQRS.Tests/InvalidReturnCommandHandler.cs new file mode 100644 index 0000000..bdcdb4a --- /dev/null +++ b/tests/Logitar.CQRS.Tests/InvalidReturnCommandHandler.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal class InvalidReturnCommandHandler +{ + public Unit HandleAsync(ICommand command, CancellationToken cancellationToken) => Unit.Value; +} diff --git a/tests/Logitar.CQRS.Tests/Logitar.CQRS.Tests.csproj b/tests/Logitar.CQRS.Tests/Logitar.CQRS.Tests.csproj new file mode 100644 index 0000000..572ba10 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/Logitar.CQRS.Tests.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + enable + enable + false + + + + True + + + + True + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Logitar.CQRS.Tests/NotImplementedCommandHandler.cs b/tests/Logitar.CQRS.Tests/NotImplementedCommandHandler.cs new file mode 100644 index 0000000..43997b3 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/NotImplementedCommandHandler.cs @@ -0,0 +1,9 @@ +namespace Logitar.CQRS.Tests; + +internal class NotImplementedCommandHandler : ICommandHandler +{ + public Task HandleAsync(Command command, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} diff --git a/tests/Logitar.CQRS.Tests/Traits.cs b/tests/Logitar.CQRS.Tests/Traits.cs new file mode 100644 index 0000000..fb84459 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/Traits.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal static class Traits +{ + public const string Category = "Category"; +} From bfed0ba3f4c911d19a572d35a3c75d18d4407b73 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 8 Dec 2025 23:09:33 -0500 Subject: [PATCH 7/8] QueryBus --- lib/Logitar.CQRS/QueryBus.cs | 195 ++++++++ tests/Logitar.CQRS.Tests/FakeQueryBus.cs | 27 ++ .../HandlelessQueryHandler.cs | 3 + tests/Logitar.CQRS.Tests/InvalidQueryBus.cs | 23 + .../InvalidReturnQueryHandler.cs | 6 + .../NotImplementedQueryHandler.cs | 9 + tests/Logitar.CQRS.Tests/Query.cs | 3 + tests/Logitar.CQRS.Tests/QueryBusTests.cs | 442 ++++++++++++++++++ tests/Logitar.CQRS.Tests/QueryHandler.cs | 6 + 9 files changed, 714 insertions(+) create mode 100644 lib/Logitar.CQRS/QueryBus.cs create mode 100644 tests/Logitar.CQRS.Tests/FakeQueryBus.cs create mode 100644 tests/Logitar.CQRS.Tests/HandlelessQueryHandler.cs create mode 100644 tests/Logitar.CQRS.Tests/InvalidQueryBus.cs create mode 100644 tests/Logitar.CQRS.Tests/InvalidReturnQueryHandler.cs create mode 100644 tests/Logitar.CQRS.Tests/NotImplementedQueryHandler.cs create mode 100644 tests/Logitar.CQRS.Tests/Query.cs create mode 100644 tests/Logitar.CQRS.Tests/QueryBusTests.cs create mode 100644 tests/Logitar.CQRS.Tests/QueryHandler.cs diff --git a/lib/Logitar.CQRS/QueryBus.cs b/lib/Logitar.CQRS/QueryBus.cs new file mode 100644 index 0000000..5354b28 --- /dev/null +++ b/lib/Logitar.CQRS/QueryBus.cs @@ -0,0 +1,195 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Logitar.CQRS; + +/// +/// Represents an in-memory bus in which sent querys are executed synchronously. +/// +public class QueryBus : IQueryBus +{ + /// + /// The name of the query handler method. + /// + protected const string HandlerName = nameof(IQueryHandler<,>.HandleAsync); + + /// + /// Gets the logger. + /// + protected virtual ILogger? Logger { get; } + /// + /// Gets the pseudo-random number generator. + /// + protected virtual Random Random { get; } = new(); + /// + /// Gets the service provider. + /// + protected virtual IServiceProvider ServiceProvider { get; } + /// + /// Gets the retry settings. + /// + protected virtual RetrySettings Settings { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The service provider. + public QueryBus(IServiceProvider serviceProvider) + { + Logger = serviceProvider.GetService>(); + ServiceProvider = serviceProvider; + Settings = serviceProvider.GetService() ?? new(); + } + + /// + /// Executes the specified query. + /// + /// The type of the query result. + /// The query to execute. + /// The cancellation token. + /// The query result. + /// The handler did not define the handle method, or it did not return a task. + public virtual async Task ExecuteAsync(IQuery query, CancellationToken cancellationToken) + { + Settings.Validate(); + + object handler = await GetHandlerAsync(query, cancellationToken); + + Type handlerType = handler.GetType(); + Type queryType = query.GetType(); + Type[] parameterTypes = [queryType, typeof(CancellationToken)]; + MethodInfo handle = handlerType.GetMethod(HandlerName, parameterTypes) + ?? throw new InvalidOperationException($"The handler {handlerType} must define a '{HandlerName}' method."); + + object[] parameters = [query, cancellationToken]; + Exception? innerException; + int attempt = 0; + while (true) + { + attempt++; + try + { + object? result = handle.Invoke(handler, parameters); + if (result is not Task task) + { + throw new InvalidOperationException($"The handler {handlerType} {HandlerName} method must return a {nameof(Task)}."); + } + return await task; + } + catch (Exception exception) + { + if (!ShouldRetry(query, exception)) + { + throw; + } + innerException = exception; + + int millisecondsDelay = CalculateMillisecondsDelay(query, exception, attempt); + if (millisecondsDelay < 0) + { + throw new InvalidOperationException($"The retry delay '{millisecondsDelay}' should be greater than or equal to 0ms."); + } + + if (Settings.Algorithm == RetryAlgorithm.None + || (Settings.MaximumRetries > 0 && attempt > Settings.MaximumRetries) + || (Settings.MaximumDelay > 0 && millisecondsDelay > Settings.MaximumDelay)) + { + break; + } + + if (Logger is not null && Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning(exception, "Query '{Query}' execution failed at attempt {Attempt}, will retry in {Delay}ms.", queryType, attempt, millisecondsDelay); + } + + if (millisecondsDelay > 0) + { + await Task.Delay(millisecondsDelay, cancellationToken); + } + } + } + + throw new InvalidOperationException($"Query '{queryType}' execution failed after {attempt} attempts. See inner exception for more detail.", innerException); + } + + /// + /// Finds the handler for the specified query. + /// + /// The type of the query result. + /// The query. + /// The cancellation token. + /// The query handler. + /// There is no handler or many handlers for the specified query. + protected virtual async Task GetHandlerAsync(IQuery query, CancellationToken cancellationToken) + { + Type queryType = query.GetType(); + IEnumerable handlers = ServiceProvider.GetServices(typeof(IQueryHandler<,>).MakeGenericType(queryType, typeof(TResult))) + .Where(handler => handler is not null) + .Select(handler => handler!); + int count = handlers.Count(); + if (count != 1) + { + StringBuilder message = new StringBuilder("Exactly one handler was expected for query of type '").Append(queryType).Append("', but "); + if (count < 1) + { + message.Append("none was found."); + } + else + { + message.Append(count).Append(" were found."); + } + throw new InvalidOperationException(message.ToString()); + } + return handlers.Single(); + } + + /// + /// Determines if the query execution should be retried or not. + /// + /// The type of the query result. + /// The query to execute. + /// The exception. + /// A value indicating whether or not the query execution should be retried. + protected virtual bool ShouldRetry(IQuery query, Exception exception) + { + return true; + } + + /// + /// Calculates the delay, in milliseconds, to wait before retrying the execution of a query after a failure. + /// The delay is computed according to the retry algorithm and configuration defined in . + /// + /// The type of the query result. + /// The query to execute. + /// The exception. + /// The current retry attempt number, starting at 1. + /// The number of milliseconds to wait before retrying. Returns 0 when retrying should occur immediately or when the configured delay or algorithm does not produce a positive value. + protected virtual int CalculateMillisecondsDelay(IQuery query, Exception exception, int attempt) + { + if (Settings.Delay > 0) + { + switch (Settings.Algorithm) + { + case RetryAlgorithm.Exponential: + if (Settings.ExponentialBase > 1) + { + return (int)Math.Pow(Settings.ExponentialBase, attempt - 1) * Settings.Delay; + } + break; + case RetryAlgorithm.Fixed: + return Settings.Delay; + case RetryAlgorithm.Linear: + return attempt * Settings.Delay; + case RetryAlgorithm.Random: + if (Settings.RandomVariation > 0 && Settings.RandomVariation < Settings.Delay) + { + int minimum = Settings.Delay - Settings.RandomVariation; + int maximum = Settings.Delay + Settings.RandomVariation; + return Random.Next(minimum, maximum + 1); + } + break; + } + } + return 0; + } +} diff --git a/tests/Logitar.CQRS.Tests/FakeQueryBus.cs b/tests/Logitar.CQRS.Tests/FakeQueryBus.cs new file mode 100644 index 0000000..adc089a --- /dev/null +++ b/tests/Logitar.CQRS.Tests/FakeQueryBus.cs @@ -0,0 +1,27 @@ +namespace Logitar.CQRS.Tests; + +internal class FakeQueryBus : QueryBus +{ + public FakeQueryBus(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + protected override bool ShouldRetry(IQuery query, Exception exception) + { + if (exception is TargetInvocationException targetInvocation && targetInvocation.InnerException is not null) + { + exception = targetInvocation.InnerException; + } + return exception is not NotImplementedException; + } + + public new async Task GetHandlerAsync(IQuery query, CancellationToken cancellationToken) + { + return await base.GetHandlerAsync(query, cancellationToken); + } + + public new int CalculateMillisecondsDelay(IQuery query, Exception exception, int attempt) + { + return base.CalculateMillisecondsDelay(query, exception, attempt); + } +} diff --git a/tests/Logitar.CQRS.Tests/HandlelessQueryHandler.cs b/tests/Logitar.CQRS.Tests/HandlelessQueryHandler.cs new file mode 100644 index 0000000..e34d864 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/HandlelessQueryHandler.cs @@ -0,0 +1,3 @@ +namespace Logitar.CQRS.Tests; + +internal class HandlelessQueryHandler; diff --git a/tests/Logitar.CQRS.Tests/InvalidQueryBus.cs b/tests/Logitar.CQRS.Tests/InvalidQueryBus.cs new file mode 100644 index 0000000..a6f1e82 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/InvalidQueryBus.cs @@ -0,0 +1,23 @@ +namespace Logitar.CQRS.Tests; + +internal class InvalidQueryBus : QueryBus +{ + private readonly object _handler; + private readonly int _millisecondsDelay; + + public InvalidQueryBus(IServiceProvider serviceProvider, object handler, int millisecondsDelay = 0) : base(serviceProvider) + { + _handler = handler; + _millisecondsDelay = millisecondsDelay; + } + + protected override Task GetHandlerAsync(IQuery query, CancellationToken cancellationToken) + { + return Task.FromResult(_handler); + } + + protected override int CalculateMillisecondsDelay(IQuery query, Exception exception, int attempt) + { + return _millisecondsDelay; + } +} diff --git a/tests/Logitar.CQRS.Tests/InvalidReturnQueryHandler.cs b/tests/Logitar.CQRS.Tests/InvalidReturnQueryHandler.cs new file mode 100644 index 0000000..15124f9 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/InvalidReturnQueryHandler.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal class InvalidReturnQueryHandler +{ + public Unit HandleAsync(IQuery query, CancellationToken cancellationToken) => Unit.Value; +} diff --git a/tests/Logitar.CQRS.Tests/NotImplementedQueryHandler.cs b/tests/Logitar.CQRS.Tests/NotImplementedQueryHandler.cs new file mode 100644 index 0000000..361c5f4 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/NotImplementedQueryHandler.cs @@ -0,0 +1,9 @@ +namespace Logitar.CQRS.Tests; + +internal class NotImplementedQueryHandler : IQueryHandler +{ + public Task HandleAsync(Query query, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} diff --git a/tests/Logitar.CQRS.Tests/Query.cs b/tests/Logitar.CQRS.Tests/Query.cs new file mode 100644 index 0000000..8a5850d --- /dev/null +++ b/tests/Logitar.CQRS.Tests/Query.cs @@ -0,0 +1,3 @@ +namespace Logitar.CQRS.Tests; + +public record Query : IQuery; diff --git a/tests/Logitar.CQRS.Tests/QueryBusTests.cs b/tests/Logitar.CQRS.Tests/QueryBusTests.cs new file mode 100644 index 0000000..217068a --- /dev/null +++ b/tests/Logitar.CQRS.Tests/QueryBusTests.cs @@ -0,0 +1,442 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Logitar.CQRS.Tests; + +[Trait(Traits.Category, Categories.Unit)] +public class QueryBusTests +{ + private readonly CancellationToken _cancellationToken = default; + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the algorithm is None.")] + public void Given_AlgorithmIsNone_When_CalculateMillisecondsDelay_Then_ZeroReturned() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.None, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the delay is zero or negative.")] + [InlineData(0)] + [InlineData(-1)] + public void Given_ZeroOrNegativeDelay_When_CalculateMillisecondsDelay_Then_ZeroReturned(int delay) + { + Assert.True(delay <= 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Fixed, + Delay = delay + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the exponential base is less than 2.")] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + public void Given_ExponentialBaseLessThanTwo_When_CalculateMillisecondsDelay_Then_ZeroReturned(int exponentialBase) + { + Assert.True(exponentialBase < 2); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Exponential, + Delay = 100, + ExponentialBase = exponentialBase + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the random variation is greater than or equal to the delay.")] + [InlineData(100, 100)] + [InlineData(100, 1000)] + public void Given_RandomVariationGreaterThanOrEqualToDelay_When_CalculateMillisecondsDelay_Then_ZeroReturned(int delay, int randomVariation) + { + Assert.True(delay > 0); + Assert.True(randomVariation > 0); + Assert.True(delay <= randomVariation); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 100, + RandomVariation = randomVariation + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the random variation is zero or negative.")] + [InlineData(0)] + [InlineData(-1)] + public void Given_ZeroOrNegativeRandomVariation_When_CalculateMillisecondsDelay_Then_ZeroReturned(int randomVariation) + { + Assert.True(randomVariation <= 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 100, + RandomVariation = randomVariation + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return the correct exponential delay.")] + [InlineData(100, 10, 2, 1000)] + [InlineData(100, 2, 5, 1600)] + public void Given_Exponential_When_CalculateMillisecondsDelay_Then_CorrectDelay(int delay, int exponentialBase, int attempt, int millisecondsDelay) + { + Assert.True(delay > 0); + Assert.True(exponentialBase > 1); + Assert.True(attempt > 0); + Assert.True(millisecondsDelay > 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Exponential, + Delay = delay, + ExponentialBase = exponentialBase + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + Assert.Equal(millisecondsDelay, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct fixed delay.")] + public void Given_Fixed_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 10; + Assert.Equal(settings.Delay, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct linear delay.")] + public void Given_Linear_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Linear, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 5; + Assert.Equal(settings.Delay * attempt, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct random delay.")] + public void Given_Random_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 500, + RandomVariation = 450 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 5; + int delay = queryBus.CalculateMillisecondsDelay(query, exception, attempt); + + int minimum = settings.Delay - settings.RandomVariation; + int maximum = settings.Delay + settings.RandomVariation; + Assert.True(minimum <= delay && delay <= maximum); + } + + [Fact(DisplayName = "ExecuteAsync: it should log a warning when an execution is retried.")] + public async Task Given_Retry_When_ExecuteAsync_Then_WarningLogged() + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedQueryHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100, + MaximumRetries = 2 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + QueryBus queryBus = new(serviceProvider); + + Query query = new(); + await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.AtLeastOnce()); + } + + [Fact(DisplayName = "ExecuteAsync: it should rethrow the exception when it should not be retried.")] + public async Task Given_ExceptionNotRetried_When_ExecuteAsync_Then_ExceptionRethrown() + { + ServiceCollection services = new(); + services.AddSingleton, NotImplementedQueryHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + Assert.IsType(exception.InnerException); + } + + [Fact(DisplayName = "ExecuteAsync: it should retry the execution given a maximum delay.")] + public async Task Given_MaximumDelay_When_ExecuteAsync_Then_RetriedUntilReached() + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedQueryHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Exponential, + Delay = 100, + ExponentialBase = 2, + MaximumDelay = 1000 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + QueryBus queryBus = new(serviceProvider); + + Query query = new(); + await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.Exactly(4)); // NOTE(fpion): 100, 200, 400, 800 + } + + [Theory(DisplayName = "ExecuteAsync: it should retry the execution given maximum retries.")] + [InlineData(1)] + [InlineData(5)] + public async Task Given_MaximumRetries_When_ExecuteAsync_Then_RetriedUntilReached(int maximumRetries) + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedQueryHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100, + MaximumRetries = maximumRetries + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + QueryBus queryBus = new(serviceProvider); + + Query query = new(); + await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.Exactly(maximumRetries)); + } + + [Fact(DisplayName = "ExecuteAsync: it should return the execution result when it succeeded.")] + public async Task Given_Success_When_ExecuteAsync_Then_ExecutionResult() + { + ServiceCollection services = new(); + services.AddSingleton, QueryHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Unit result = await queryBus.ExecuteAsync(query, _cancellationToken); + Assert.Equal(Unit.Value, result); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the execution failed.")] + public async Task Given_ExecutionFailed_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton, NotImplementedQueryHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + QueryBus queryBus = new(serviceProvider); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + Assert.Equal($"Query '{query.GetType()}' execution failed after 1 attempts. See inner exception for more detail.", exception.Message); + Assert.IsType(exception.InnerException); + Assert.IsType(exception.InnerException.InnerException); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the handler does not have a handle.")] + public async Task Given_HandlerWithoutHandle_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidQueryBus queryBus = new(serviceProvider, new HandlelessQueryHandler()); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + Assert.Equal($"The handler {typeof(HandlelessQueryHandler)} must define a 'HandleAsync' method.", exception.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the handler does not return a task.")] + public async Task Given_HandlerDoesNotReturnTask_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidQueryBus queryBus = new(serviceProvider, new InvalidReturnQueryHandler()); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + Assert.IsType(exception.InnerException); + Assert.Equal($"The handler {typeof(InvalidReturnQueryHandler)} HandleAsync method must return a Task.", exception.InnerException.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the milliseconds delay is negative.")] + public async Task Given_NegativeDelay_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidQueryBus queryBus = new(serviceProvider, new NotImplementedQueryHandler(), millisecondsDelay: -1); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + Assert.Equal("The retry delay '-1' should be greater than or equal to 0ms.", exception.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the settings are not valid.")] + public async Task Given_InvalidSettings_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton(new RetrySettings + { + Delay = -1 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + QueryBus queryBus = new(serviceProvider); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than or equal to 0.", lines[1]); + } + + [Fact(DisplayName = "GetHandlerAsync: it should return the handler found.")] + public async Task Given_SingleHandler_When_GetHandlerAsync_Then_HandlerReturned() + { + ServiceCollection services = new(); + services.AddSingleton, QueryHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + object handler = await queryBus.GetHandlerAsync(query, _cancellationToken); + Assert.IsType(handler); + } + + [Fact(DisplayName = "GetHandlerAsync: it should throw InvalidOperationException when many handlers were found.")] + public async Task Given_ManyHandlers_When_GetHandlerAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton, QueryHandler>(); + services.AddSingleton, QueryHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.GetHandlerAsync(query, _cancellationToken)); + Assert.Equal($"Exactly one handler was expected for query of type '{query.GetType()}', but 2 were found.", exception.Message); + } + + [Fact(DisplayName = "GetHandlerAsync: it should throw InvalidOperationException when no handler was found.")] + public async Task Given_NoHandler_When_GetHandlerAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.GetHandlerAsync(query, _cancellationToken)); + Assert.Equal($"Exactly one handler was expected for query of type '{query.GetType()}', but none was found.", exception.Message); + } +} diff --git a/tests/Logitar.CQRS.Tests/QueryHandler.cs b/tests/Logitar.CQRS.Tests/QueryHandler.cs new file mode 100644 index 0000000..e94007f --- /dev/null +++ b/tests/Logitar.CQRS.Tests/QueryHandler.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal class QueryHandler : IQueryHandler +{ + public Task HandleAsync(Query query, CancellationToken cancellationToken) => Unit.CompletedTask; +} From 3a38f5ab760331ddbbb025da531ee792e1970d34 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 8 Dec 2025 23:24:49 -0500 Subject: [PATCH 8/8] test --- lib/Logitar.CQRS/RetrySettings.cs | 16 +-- .../Logitar.CQRS.Tests/RetrySettingsTests.cs | 105 ++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 tests/Logitar.CQRS.Tests/RetrySettingsTests.cs diff --git a/lib/Logitar.CQRS/RetrySettings.cs b/lib/Logitar.CQRS/RetrySettings.cs index 01d7c5d..3a035bc 100644 --- a/lib/Logitar.CQRS/RetrySettings.cs +++ b/lib/Logitar.CQRS/RetrySettings.cs @@ -86,15 +86,17 @@ public void Validate() errors.Add($"'{nameof(ExponentialBase)}' must be greater than 1."); } break; + case RetryAlgorithm.Fixed: + if (MaximumDelay > 0) + { + errors.Add($"'{nameof(MaximumDelay)}' must be 0 when '{nameof(Algorithm)}' is {Algorithm}."); + } + break; case RetryAlgorithm.Linear: if (Delay <= 0) { errors.Add($"'{nameof(Delay)}' must be greater than 0."); } - if (MaximumDelay > 0) - { - errors.Add($"'{nameof(Delay)}' must be 0 when '{nameof(Algorithm)}' is {Algorithm}."); - } break; case RetryAlgorithm.Random: if (Delay <= 0) @@ -111,14 +113,14 @@ public void Validate() } if (MaximumDelay > 0) { - errors.Add($"'{nameof(Delay)}' must be 0 when '{nameof(Algorithm)}' is {Algorithm}."); + errors.Add($"'{nameof(MaximumDelay)}' must be 0 when '{nameof(Algorithm)}' is {Algorithm}."); } break; - case RetryAlgorithm.Fixed: case RetryAlgorithm.None: break; default: - throw new ArgumentOutOfRangeException(nameof(Algorithm)); + errors.Add($"'{nameof(Algorithm)}' is not a valid retry algorithm."); + break; } if (MaximumRetries < 0) diff --git a/tests/Logitar.CQRS.Tests/RetrySettingsTests.cs b/tests/Logitar.CQRS.Tests/RetrySettingsTests.cs new file mode 100644 index 0000000..33712ee --- /dev/null +++ b/tests/Logitar.CQRS.Tests/RetrySettingsTests.cs @@ -0,0 +1,105 @@ +namespace Logitar.CQRS.Tests; + +[Trait(Traits.Category, Categories.Unit)] +public class RetrySettingsTests +{ + [Fact(DisplayName = "Validate: it should not throw when the validation succeeded.")] + public void Given_Succeeded_When_Validate_Then_NothingThrown() + { + RetrySettings settings = new() + { + RandomVariation = 1000, + MaximumDelay = 1000 + }; + settings.Validate(); + } + + [Fact(DisplayName = "Validate: it should throw InvalidOperationException when the Exponential properties are not valid.")] + public void Given_InvalidExponentialProperties_When_Validate_Then_InvalidOperationException() + { + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Exponential, + Delay = 0, + ExponentialBase = 1 + }; + var exception = Assert.Throws(settings.Validate); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than 0.", lines[1]); + Assert.Equal(" - 'ExponentialBase' must be greater than 1.", lines[2]); + } + + [Fact(DisplayName = "Validate: it should throw InvalidOperationException when the Fixed properties are not valid.")] + public void Given_InvalidFixedProperties_When_Validate_Then_InvalidOperationException() + { + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Fixed, + MaximumDelay = 500 + }; + var exception = Assert.Throws(settings.Validate); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'MaximumDelay' must be 0 when 'Algorithm' is Fixed.", lines[1]); + } + + [Fact(DisplayName = "Validate: it should throw InvalidOperationException when the Linear properties are not valid.")] + public void Given_InvalidLinearProperties_When_Validate_Then_InvalidOperationException() + { + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Linear, + Delay = 0, + MaximumDelay = 500 + }; + var exception = Assert.Throws(settings.Validate); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than 0.", lines[1]); + } + + [Theory(DisplayName = "Validate: it should throw InvalidOperationException when the Random properties are not valid.")] + [InlineData(false)] + [InlineData(true)] + public void Given_InvalidRandomProperties_When_Validate_Then_InvalidOperationException(bool greater) + { + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 0, + RandomVariation = greater ? 1 : -1, + MaximumDelay = 1000 + }; + var exception = Assert.Throws(settings.Validate); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(4, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than 0.", lines[1]); + Assert.Equal(greater ? " - 'RandomVariation' must be less than or equal to 'Delay'." : " - 'RandomVariation' must be greater than 0.", lines[2]); + Assert.Equal(" - 'MaximumDelay' must be 0 when 'Algorithm' is Random.", lines[3]); + } + + [Fact(DisplayName = "Validate: it should throw InvalidOperationException when the shared properties are not valid.")] + public void Given_InvalidSharedProperties_When_Validate_Then_InvalidOperationException() + { + RetrySettings settings = new() + { + Algorithm = (RetryAlgorithm)(-1), + Delay = -1, + MaximumDelay = -1, + MaximumRetries = -1 + }; + var exception = Assert.Throws(settings.Validate); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(5, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than or equal to 0.", lines[1]); + Assert.Equal(" - 'MaximumDelay' must be greater than or equal to 0.", lines[2]); + Assert.Equal(" - 'Algorithm' is not a valid retry algorithm.", lines[3]); + Assert.Equal(" - 'MaximumRetries' must be greater than or equal to 0.", lines[4]); + } +}