diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index de1fc606..b7d130ae 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,7 +10,7 @@ - When modifying a public member, make sure to update the XML Documentation and the corresponding docs in the docs folder and the README file if applicable. - When modifying a public member, make sure to check the Public.API files for the affected assembly and update them if necessary. - Do not ignore CS1591 warnings; analyze and add missing XML comments instead. -- Do not add comments inside methods; for complex logic, consider extracting it into a separate method with a descriptive name instead of adding comments. +- Comments inside method bodies must explain *why*, not *what*: a why-comment states a constraint, invariant, or rationale the code cannot express on its own (e.g. concurrency/ordering requirements, the reason for a workaround, a justification for otherwise-surprising deliberate behavior). Narration/what-comments — restating what the next line does, section-header comments, play-by-play commentary — are not allowed. For complex logic, prefer extracting it into a separate method with a descriptive name over adding a comment. ## Testing Guidelines - Prefer using the Vulthil.xUnit testing framework for tests. diff --git a/src/Vulthil.Messaging/Partitioning/PartitionSpec.cs b/src/Vulthil.Messaging/PartitionSpec.cs similarity index 100% rename from src/Vulthil.Messaging/Partitioning/PartitionSpec.cs rename to src/Vulthil.Messaging/PartitionSpec.cs diff --git a/src/Vulthil.Messaging/Partitioning/Partitioner.cs b/src/Vulthil.Messaging/Partitioner.cs similarity index 100% rename from src/Vulthil.Messaging/Partitioning/Partitioner.cs rename to src/Vulthil.Messaging/Partitioner.cs diff --git a/src/Vulthil.Messaging/Queues/DeadLetterDefinition.cs b/src/Vulthil.Messaging/Queues/DeadLetterDefinition.cs new file mode 100644 index 00000000..edc26de5 --- /dev/null +++ b/src/Vulthil.Messaging/Queues/DeadLetterDefinition.cs @@ -0,0 +1,20 @@ +namespace Vulthil.Messaging.Queues; + +/// +/// Describes the dead letter queue and exchange configuration. +/// +public sealed record DeadLetterDefinition +{ + /// + /// Gets or sets the dead letter queue name, or to use the default. + /// + public string? QueueName { get; set; } + /// + /// Gets or sets the dead letter exchange name, or to use the default. + /// + public string? ExchangeName { get; set; } + /// + /// Gets or sets a value indicating whether dead letter routing is enabled. + /// + public bool Enabled { get; set; } +} diff --git a/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs b/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs index ed38b720..f52561dc 100644 --- a/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs @@ -1,5 +1,4 @@ using System.Reflection; -using System.Security.Cryptography; using Vulthil.Messaging.Abstractions.Consumers; namespace Vulthil.Messaging.Queues; @@ -79,263 +78,3 @@ public interface IQueueConfigurator /// IQueueConfigurator UseSingleActiveConsumer(); } - -/// -/// Defines a retry policy including intervals, jitter, and exception filtering. -/// -public sealed record RetryPolicyDefinition -{ - /// - /// Gets or sets the maximum number of retry attempts. - /// - public int MaxRetryCount { get; set; } - /// - /// Gets or sets the jitter factor (0.0–1.0) applied to retry intervals. - /// - public double JitterFactor { get; set; } - /// - /// Gets or sets a value indicating whether retries run in-memory — the consumer is re-invoked in-process - /// while the delivery is held — rather than via delayed re-delivery through the retry queue. In-memory - /// retries preserve message order (a later message cannot overtake the one being retried), so they are - /// used automatically for partitioned queues. Defaults to (delayed re-delivery). - /// - public bool InMemory { get; set; } - /// - /// Gets the delay intervals between successive retry attempts. - /// - public ICollection Intervals { get; } = []; - /// - /// Gets the fully-qualified type names of exceptions that should be excluded from retry attempts. - /// Names outside the core library must be assembly-qualified to resolve; a name that cannot be - /// resolved is skipped (the RabbitMQ transport logs a startup warning for each such name). - /// - public ICollection IgnoreExceptions { get; } = []; - - private readonly object _ignoredTypesGate = new(); - private readonly HashSet _ignoredTypes = []; - private HashSet? _resolvedIgnoredTypes; - - internal void AddIgnoredType(Type type) => _ignoredTypes.Add(type); - /// - /// Gets the resolved CLR exception types that should be excluded from retry attempts. The set is built - /// once, on first access, from the fluently ignored types and the resolvable names in - /// ; the resolution is thread-safe and later mutations of either - /// collection do not change the result. - /// - public HashSet GetIgnoredExceptionTypes() - { - lock (_ignoredTypesGate) - { - return _resolvedIgnoredTypes ??= ResolveIgnoredExceptionTypes(); - } - } - - private HashSet ResolveIgnoredExceptionTypes() - { - var resolved = new HashSet(_ignoredTypes); - foreach (var name in IgnoreExceptions) - { - if (Type.GetType(name, false) is { } type) - { - resolved.Add(type); - } - } - - return resolved; - } - - /// - /// Calculates the delay for the specified retry attempt, applying jitter when configured. - /// Attempts beyond the last configured interval reuse that interval (capped back-off). - /// - /// The zero-based attempt index. - /// The delay before the next retry. - public TimeSpan GetDelay(int attempt) - { - if (Intervals.Count == 0) - { - return TimeSpan.Zero; - } - - var interval = GetIntervalForAttempt(attempt); - if (JitterFactor <= 0.0) - { - return interval; - } - - var jitterMultiplier = RandomNumberGeneratorExtensions.GetDouble() * 2 * JitterFactor - JitterFactor; - var finalDelay = interval.TotalMilliseconds * (1 + jitterMultiplier); - return TimeSpan.FromMilliseconds(Math.Max(0, finalDelay)); - } - - private TimeSpan GetIntervalForAttempt(int attempt) - { - var index = Math.Min(attempt, Intervals.Count - 1); - return Intervals is IList list ? list[index] : Intervals.ElementAt(index); - } -} - -internal static class RandomNumberGeneratorExtensions -{ - /// - /// Generates a cryptographically random in the range [0, 1). Retry jitter does - /// not need cryptographic strength, but the repository enforces CA5394 (no ) as an - /// error, so the secure generator is used here as well. - /// - public static double GetDouble() - { - var bytes = RandomNumberGenerator.GetBytes(8); - var ul = BitConverter.ToUInt64(bytes, 0) / (1 << 11); - return ul / (double)(1UL << 53); - } -} - -/// -/// Describes the dead letter queue and exchange configuration. -/// -public sealed record DeadLetterDefinition -{ - /// - /// Gets or sets the dead letter queue name, or to use the default. - /// - public string? QueueName { get; set; } - /// - /// Gets or sets the dead letter exchange name, or to use the default. - /// - public string? ExchangeName { get; set; } - /// - /// Gets or sets a value indicating whether dead letter routing is enabled. - /// - public bool Enabled { get; set; } -} - -/// -/// Fluent builder for constructing a . -/// -public sealed class RetryPolicyConfigurator -{ - private readonly HashSet _ignoredExceptions = []; - private List _intervals = []; - /// - /// Gets the maximum number of retry attempts configured via one of the interval methods. - /// - public int RetryLimit { get; private set; } - private double _jitterFactor; - private bool _inMemory; - /// - /// Gets the configured delay intervals between successive retry attempts. - /// - public IReadOnlyList Intervals => _intervals; - /// - /// Gets the exception types that should be excluded from retry attempts and immediately surfaced. - /// - public IReadOnlySet IgnoredExceptions => _ignoredExceptions; - - /// - /// Configures immediate retries with zero delay. - /// - /// The number of retries. - public void Immediate(int retryCount) - { - RetryLimit = retryCount; - _intervals = [.. Enumerable.Repeat(TimeSpan.Zero, retryCount)]; - } - - /// - /// Configures explicit retry intervals. - /// - /// The delay between each retry attempt. - public void SetIntervals(params TimeSpan[] intervals) - { - RetryLimit = intervals.Length; - _intervals = [.. intervals]; - } - /// - /// Adds random jitter to retry intervals. - /// - /// The jitter factor between 0.0 and 1.0. - /// Thrown when is outside the valid range. - public void UseJitter(double factor) - { - if (factor is < 0 or > 1.0) - { - throw new ArgumentOutOfRangeException(nameof(factor), "Jitter must be between 0.0 and 1.0"); - } - - _jitterFactor = factor; - } - /// - /// Configures exponential back-off retry intervals. - /// - /// The number of retries. - /// The delay for the first retry. - /// The maximum delay cap. - /// The multiplier applied to each successive interval. Default is 2.0. - public void Exponential( - int retryCount, - TimeSpan initialInterval, - TimeSpan maxInterval, - double multiplier = 2.0) - { - RetryLimit = retryCount; - var intervals = new List(); - var currentInterval = initialInterval; - - for (int i = 0; i < retryCount; i++) - { - intervals.Add(currentInterval); - - // Calculate next interval - double nextTicks = currentInterval.Ticks * multiplier; - currentInterval = TimeSpan.FromTicks((long)nextTicks); - - // Cap it at the max interval - if (currentInterval > maxInterval) - { - currentInterval = maxInterval; - } - } - - _intervals = [.. intervals]; - } - - /// - /// Adds an exception type to the ignore list; matching exceptions will not trigger retries. - /// - /// The exception type to ignore. - public void Ignore() where TException : Exception => _ignoredExceptions.Add(typeof(TException)); - - /// - /// Configures retries to run in-memory: the consumer is re-invoked in-process while the delivery is held, - /// instead of being re-delivered later through the retry queue. This preserves message order, so it is the - /// behavior used automatically for partitioned queues. - /// - public void InMemory() => _inMemory = true; - - /// - /// Builds the from the current configuration. - /// - /// The constructed retry policy definition. - public RetryPolicyDefinition Build() - { - var definition = new RetryPolicyDefinition - { - MaxRetryCount = RetryLimit, - JitterFactor = _jitterFactor, - InMemory = _inMemory - }; - - foreach (var interval in _intervals) - { - definition.Intervals.Add(interval); - } - - foreach (var type in _ignoredExceptions) - { - definition.AddIgnoredType(type); - definition.IgnoreExceptions.Add(type.AssemblyQualifiedName!); - } - - return definition; - } -} diff --git a/src/Vulthil.Messaging/Queues/QueueConfigurator.cs b/src/Vulthil.Messaging/Queues/QueueConfigurator.cs index 45a4092e..f1bf013d 100644 --- a/src/Vulthil.Messaging/Queues/QueueConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/QueueConfigurator.cs @@ -75,7 +75,6 @@ public IQueueConfigurator AddRequestConsumer(Action internal void Build() { - // 1. Auto-subscribe any concrete TMessage from consumer registrations not yet subscribed. - // Routing keys are a Subscription-level concern, so auto-subscribed - // subscriptions get a null routing key (broker uses an empty pattern). For direct/topic exchanges that - // require a specific pattern, the caller must explicitly q.Subscribe("pattern") first. + // Routing keys are a Subscription-level concern, so auto-subscribed subscriptions get a null routing + // key (broker uses an empty pattern). For direct/topic exchanges that require a specific pattern, the + // caller must explicitly q.Subscribe("pattern") first. var concreteConsumerMessageTypes = _queueDefinition.Registrations .Select(r => r.MessageType) .Where(m => m.Type is { IsAbstract: false, IsInterface: false }); @@ -181,7 +179,7 @@ internal void Build() _queueDefinition.AddSubscription(new Subscription(messageType)); } - // 2. Request consumers cannot be polymorphic — the response type is fixed and can't be selected + // Request consumers cannot be polymorphic — the response type is fixed and can't be selected // by the incoming concrete type. foreach (var rpc in _queueDefinition.Registrations.OfType()) { @@ -194,7 +192,6 @@ internal void Build() } } - // 3. Every consumer must have at least one matching concrete subscription. var orphanConsumer = _queueDefinition.Registrations.FirstOrDefault( r => !_queueDefinition.Subscriptions.Any(s => r.MessageType.Type.IsAssignableFrom(s.MessageType.Type))); if (orphanConsumer is not null) @@ -205,7 +202,6 @@ internal void Build() "Call q.Subscribe() or q.SubscribeAll(assembly) for at least one implementer."); } - // 4. Every subscription must have at least one matching consumer. var orphanSubscription = _queueDefinition.Subscriptions.FirstOrDefault( s => !_queueDefinition.Registrations.Any(r => r.MessageType.Type.IsAssignableFrom(s.MessageType.Type))); if (orphanSubscription is not null) diff --git a/src/Vulthil.Messaging/Queues/RandomNumberGeneratorExtensions.cs b/src/Vulthil.Messaging/Queues/RandomNumberGeneratorExtensions.cs new file mode 100644 index 00000000..d27793ae --- /dev/null +++ b/src/Vulthil.Messaging/Queues/RandomNumberGeneratorExtensions.cs @@ -0,0 +1,18 @@ +using System.Security.Cryptography; + +namespace Vulthil.Messaging.Queues; + +internal static class RandomNumberGeneratorExtensions +{ + /// + /// Generates a cryptographically random in the range [0, 1). Retry jitter does + /// not need cryptographic strength, but the repository enforces CA5394 (no ) as an + /// error, so the secure generator is used here as well. + /// + public static double GetDouble() + { + var bytes = RandomNumberGenerator.GetBytes(8); + var ul = BitConverter.ToUInt64(bytes, 0) / (1 << 11); + return ul / (double)(1UL << 53); + } +} diff --git a/src/Vulthil.Messaging/Queues/RetryPolicyConfigurator.cs b/src/Vulthil.Messaging/Queues/RetryPolicyConfigurator.cs new file mode 100644 index 00000000..9bd0f694 --- /dev/null +++ b/src/Vulthil.Messaging/Queues/RetryPolicyConfigurator.cs @@ -0,0 +1,130 @@ +namespace Vulthil.Messaging.Queues; + +/// +/// Fluent builder for constructing a . +/// +public sealed class RetryPolicyConfigurator +{ + private readonly HashSet _ignoredExceptions = []; + private List _intervals = []; + /// + /// Gets the maximum number of retry attempts configured via one of the interval methods. + /// + public int RetryLimit { get; private set; } + private double _jitterFactor; + private bool _inMemory; + /// + /// Gets the configured delay intervals between successive retry attempts. + /// + public IReadOnlyList Intervals => _intervals; + /// + /// Gets the exception types that should be excluded from retry attempts and immediately surfaced. + /// + public IReadOnlySet IgnoredExceptions => _ignoredExceptions; + + /// + /// Configures immediate retries with zero delay. + /// + /// The number of retries. + public void Immediate(int retryCount) + { + RetryLimit = retryCount; + _intervals = [.. Enumerable.Repeat(TimeSpan.Zero, retryCount)]; + } + + /// + /// Configures explicit retry intervals. + /// + /// The delay between each retry attempt. + public void SetIntervals(params TimeSpan[] intervals) + { + RetryLimit = intervals.Length; + _intervals = [.. intervals]; + } + /// + /// Adds random jitter to retry intervals. + /// + /// The jitter factor between 0.0 and 1.0. + /// Thrown when is outside the valid range. + public void UseJitter(double factor) + { + if (factor is < 0 or > 1.0) + { + throw new ArgumentOutOfRangeException(nameof(factor), "Jitter must be between 0.0 and 1.0"); + } + + _jitterFactor = factor; + } + /// + /// Configures exponential back-off retry intervals. + /// + /// The number of retries. + /// The delay for the first retry. + /// The maximum delay cap. + /// The multiplier applied to each successive interval. Default is 2.0. + public void Exponential( + int retryCount, + TimeSpan initialInterval, + TimeSpan maxInterval, + double multiplier = 2.0) + { + RetryLimit = retryCount; + var intervals = new List(); + var currentInterval = initialInterval; + + for (int i = 0; i < retryCount; i++) + { + intervals.Add(currentInterval); + + double nextTicks = currentInterval.Ticks * multiplier; + currentInterval = TimeSpan.FromTicks((long)nextTicks); + + if (currentInterval > maxInterval) + { + currentInterval = maxInterval; + } + } + + _intervals = [.. intervals]; + } + + /// + /// Adds an exception type to the ignore list; matching exceptions will not trigger retries. + /// + /// The exception type to ignore. + public void Ignore() where TException : Exception => _ignoredExceptions.Add(typeof(TException)); + + /// + /// Configures retries to run in-memory: the consumer is re-invoked in-process while the delivery is held, + /// instead of being re-delivered later through the retry queue. This preserves message order, so it is the + /// behavior used automatically for partitioned queues. + /// + public void InMemory() => _inMemory = true; + + /// + /// Builds the from the current configuration. + /// + /// The constructed retry policy definition. + public RetryPolicyDefinition Build() + { + var definition = new RetryPolicyDefinition + { + MaxRetryCount = RetryLimit, + JitterFactor = _jitterFactor, + InMemory = _inMemory + }; + + foreach (var interval in _intervals) + { + definition.Intervals.Add(interval); + } + + foreach (var type in _ignoredExceptions) + { + definition.AddIgnoredType(type); + definition.IgnoreExceptions.Add(type.AssemblyQualifiedName!); + } + + return definition; + } +} diff --git a/src/Vulthil.Messaging/Queues/RetryPolicyDefinition.cs b/src/Vulthil.Messaging/Queues/RetryPolicyDefinition.cs new file mode 100644 index 00000000..76bd5022 --- /dev/null +++ b/src/Vulthil.Messaging/Queues/RetryPolicyDefinition.cs @@ -0,0 +1,96 @@ +namespace Vulthil.Messaging.Queues; + +/// +/// Defines a retry policy including intervals, jitter, and exception filtering. +/// +public sealed record RetryPolicyDefinition +{ + /// + /// Gets or sets the maximum number of retry attempts. + /// + public int MaxRetryCount { get; set; } + /// + /// Gets or sets the jitter factor (0.0–1.0) applied to retry intervals. + /// + public double JitterFactor { get; set; } + /// + /// Gets or sets a value indicating whether retries run in-memory — the consumer is re-invoked in-process + /// while the delivery is held — rather than via delayed re-delivery through the retry queue. In-memory + /// retries preserve message order (a later message cannot overtake the one being retried), so they are + /// used automatically for partitioned queues. Defaults to (delayed re-delivery). + /// + public bool InMemory { get; set; } + /// + /// Gets the delay intervals between successive retry attempts. + /// + public ICollection Intervals { get; } = []; + /// + /// Gets the fully-qualified type names of exceptions that should be excluded from retry attempts. + /// Names outside the core library must be assembly-qualified to resolve; a name that cannot be + /// resolved is skipped (the RabbitMQ transport logs a startup warning for each such name). + /// + public ICollection IgnoreExceptions { get; } = []; + + private readonly object _ignoredTypesGate = new(); + private readonly HashSet _ignoredTypes = []; + private HashSet? _resolvedIgnoredTypes; + + internal void AddIgnoredType(Type type) => _ignoredTypes.Add(type); + /// + /// Gets the resolved CLR exception types that should be excluded from retry attempts. The set is built + /// once, on first access, from the fluently ignored types and the resolvable names in + /// ; the resolution is thread-safe and later mutations of either + /// collection do not change the result. + /// + public HashSet GetIgnoredExceptionTypes() + { + lock (_ignoredTypesGate) + { + return _resolvedIgnoredTypes ??= ResolveIgnoredExceptionTypes(); + } + } + + private HashSet ResolveIgnoredExceptionTypes() + { + var resolved = new HashSet(_ignoredTypes); + foreach (var name in IgnoreExceptions) + { + if (Type.GetType(name, false) is { } type) + { + resolved.Add(type); + } + } + + return resolved; + } + + /// + /// Calculates the delay for the specified retry attempt, applying jitter when configured. + /// Attempts beyond the last configured interval reuse that interval (capped back-off). + /// + /// The zero-based attempt index. + /// The delay before the next retry. + public TimeSpan GetDelay(int attempt) + { + if (Intervals.Count == 0) + { + return TimeSpan.Zero; + } + + var interval = GetIntervalForAttempt(attempt); + if (JitterFactor <= 0.0) + { + return interval; + } + + var jitterMultiplier = RandomNumberGeneratorExtensions.GetDouble() * 2 * JitterFactor - JitterFactor; + var finalDelay = interval.TotalMilliseconds * (1 + jitterMultiplier); + return TimeSpan.FromMilliseconds(Math.Max(0, finalDelay)); + } + + private TimeSpan GetIntervalForAttempt(int attempt) + { + var index = Math.Min(attempt, Intervals.Count - 1); + return Intervals is IList list ? list[index] : Intervals.ElementAt(index); + } +} diff --git a/src/Vulthil.SharedKernel.Infrastructure.Cosmos/DependencyInjectionExtensions.cs b/src/Vulthil.SharedKernel.Infrastructure.Cosmos/DependencyInjectionExtensions.cs index 357941e6..ab856a89 100644 --- a/src/Vulthil.SharedKernel.Infrastructure.Cosmos/DependencyInjectionExtensions.cs +++ b/src/Vulthil.SharedKernel.Infrastructure.Cosmos/DependencyInjectionExtensions.cs @@ -25,6 +25,8 @@ public static IDatabaseInfrastructureConfigurator UseCosmosDb? configureDbContextOptions = null) where TDbContext : DbContext, ISaveOutboxMessages { + ArgumentNullException.ThrowIfNull(configurator); + configurator.OnConfigured(c => { if (c is not DatabaseInfrastructureConfigurator { OutboxStoreCustomized: true }) diff --git a/src/Vulthil.SharedKernel.Infrastructure.Relational/OutboxCommitInterceptor.cs b/src/Vulthil.SharedKernel.Infrastructure.Relational/OutboxProcessing/OutboxCommitInterceptor.cs similarity index 100% rename from src/Vulthil.SharedKernel.Infrastructure.Relational/OutboxCommitInterceptor.cs rename to src/Vulthil.SharedKernel.Infrastructure.Relational/OutboxProcessing/OutboxCommitInterceptor.cs diff --git a/src/Vulthil.SharedKernel.Infrastructure.Relational/RelationalOutboxStore.cs b/src/Vulthil.SharedKernel.Infrastructure.Relational/OutboxProcessing/RelationalOutboxStore.cs similarity index 100% rename from src/Vulthil.SharedKernel.Infrastructure.Relational/RelationalOutboxStore.cs rename to src/Vulthil.SharedKernel.Infrastructure.Relational/OutboxProcessing/RelationalOutboxStore.cs diff --git a/tests/Vulthil.IntegrationTests/ProviderOutboxRegistrationTests.cs b/tests/Vulthil.IntegrationTests/ProviderOutboxRegistrationTests.cs index 672bf0fb..e8593640 100644 --- a/tests/Vulthil.IntegrationTests/ProviderOutboxRegistrationTests.cs +++ b/tests/Vulthil.IntegrationTests/ProviderOutboxRegistrationTests.cs @@ -170,6 +170,19 @@ public void UseCosmosDbPreservesAUserStoreSelectedAfterIt() OutboxStoreDescriptor(builder).ImplementationType.ShouldBe(typeof(CustomOutboxStore)); } + [Fact] + public void UseCosmosDbThrowsWhenConfiguratorIsNull() + { + // Arrange + IDatabaseInfrastructureConfigurator configurator = null!; + + // Act + var act = () => configurator.UseCosmosDb(ConnectionStringKey); + + // Assert + Should.Throw(act); + } + private static HostApplicationBuilder NewBuilder(string connectionString) { var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings());