Introduce RabbitMQ consumer pool for parallel processing
Enhanced RabbitMQ email processing by introducing a `SendingEmailConsumerPool` to enable the competing consumers pattern. Each consumer operates on its own channel, improving scalability and thread safety. - Added `SendingEmailConsumerPool` to manage multiple consumers. - Updated `DependencyInjection` to register the consumer pool. - Refactored `SendingEmailConsumer` for better logging and error handling. - Updated `AsyncInitWorker` to initialize the consumer pool. - Added `ConsumerConcurrency` to RabbitMQ configuration. - Improved error handling in `LimilabsEmailService` with detailed SMTP error messages.
This commit is contained in:
@@ -33,7 +33,7 @@ public static class DependencyInjection
|
|||||||
services.AddSingleton<IEncryptionService, DataProtectionEncryptionService>();
|
services.AddSingleton<IEncryptionService, DataProtectionEncryptionService>();
|
||||||
|
|
||||||
// --- Email Queue (RabbitMQ) ---
|
// --- Email Queue (RabbitMQ) ---
|
||||||
services.AddSingleton<SendingEmailConsumer>();
|
services.AddSingleton<SendingEmailConsumerPool>();
|
||||||
services.AddMessagingServicePublisher();
|
services.AddMessagingServicePublisher();
|
||||||
|
|
||||||
// --- RabbitMQ Configuration ---
|
// --- RabbitMQ Configuration ---
|
||||||
|
|||||||
@@ -4,20 +4,19 @@ using DigitalData.MessagingService.Application.Common.Interfaces;
|
|||||||
using DigitalData.MessagingService.Abstraction;
|
using DigitalData.MessagingService.Abstraction;
|
||||||
using DigitalData.MessagingService.RabbitMQ;
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using RabbitMQ.Client;
|
using RabbitMQ.Client;
|
||||||
using RabbitMQ.Client.Events;
|
using RabbitMQ.Client.Events;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// RabbitMQ-based email queue implementation for outgoing emails.
|
/// A single RabbitMQ consumer that processes one email message at a time on its own dedicated channel.
|
||||||
/// Provides message persistence, scalability, and reliability.
|
/// Multiple instances run in parallel via <see cref="SendingEmailConsumerPool"/> (competing consumers pattern).
|
||||||
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
|
/// Each instance owns exactly one channel — channels are not thread-safe and must not be shared.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SendingEmailConsumer : IAsyncDisposable
|
public sealed class SendingEmailConsumer : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly RabbitMqConfiguration _config;
|
private readonly string _queueName;
|
||||||
|
|
||||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
||||||
|
|
||||||
@@ -25,15 +24,27 @@ public sealed class SendingEmailConsumer : IAsyncDisposable
|
|||||||
|
|
||||||
private readonly ILogger<SendingEmailConsumer>? _logger;
|
private readonly ILogger<SendingEmailConsumer>? _logger;
|
||||||
|
|
||||||
public SendingEmailConsumer(IOptions<RabbitMqConfiguration> config, IEmailService EmailService, RabbitMqConnectionFactory CnnFactory, ILogger<SendingEmailConsumer>? logger = null)
|
/// <summary>
|
||||||
|
/// Transient identifier assigned to this consumer instance at runtime.
|
||||||
|
/// A new value is generated each time the application starts or a new consumer is created.
|
||||||
|
/// Use this to correlate log entries belonging to the same consumer session across competing instances.
|
||||||
|
/// </summary>
|
||||||
|
public Guid RuntimeId { get; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public SendingEmailConsumer(string queueName, IEmailService emailService, RabbitMqConnectionFactory cnnFactory, ILogger<SendingEmailConsumer>? logger = null)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_config = config.Value;
|
_queueName = queueName;
|
||||||
|
|
||||||
_lazyChannel = new(CnnFactory.CreateChannelAsync);
|
_lazyChannel = new(cnnFactory.CreateChannelAsync);
|
||||||
_lazyInit = new(async () => {
|
_lazyInit = new(async () =>
|
||||||
|
{
|
||||||
var channel = await _lazyChannel.Value;
|
var channel = await _lazyChannel.Value;
|
||||||
|
|
||||||
|
// prefetchCount=1 ensures this consumer processes one message at a time before acking.
|
||||||
|
// Parallelism comes from running multiple consumer instances, not from within a single channel.
|
||||||
|
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false);
|
||||||
|
|
||||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||||
|
|
||||||
consumer.ReceivedAsync += async (sender, args) =>
|
consumer.ReceivedAsync += async (sender, args) =>
|
||||||
@@ -47,10 +58,14 @@ public sealed class SendingEmailConsumer : IAsyncDisposable
|
|||||||
if (oMailEvent is not null)
|
if (oMailEvent is not null)
|
||||||
{
|
{
|
||||||
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
|
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
|
||||||
await EmailService.SendEmailAsync(oMailEvent.Mail);
|
await emailService.SendEmailAsync(oMailEvent.Mail, args.CancellationToken);
|
||||||
|
|
||||||
// Acknowledge message after successful processing
|
// Acknowledge message after successful processing
|
||||||
await channel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
|
await channel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
|
||||||
|
|
||||||
|
logger?.LogDebug(
|
||||||
|
"Email successfully sent and acknowledged. RuntimeId={RuntimeId}, Queue={QueueName}, DeliveryTag={DeliveryTag}, To={Recipients}, Subject={Subject}, EventId={EventId}",
|
||||||
|
RuntimeId, _queueName, args.DeliveryTag, oMailEvent.Mail.Recipients, oMailEvent.Mail.Subject, oMailEvent.Id);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -89,19 +104,18 @@ public sealed class SendingEmailConsumer : IAsyncDisposable
|
|||||||
|
|
||||||
// Start consuming messages (event-driven, non-blocking)
|
// Start consuming messages (event-driven, non-blocking)
|
||||||
await channel.BasicConsumeAsync(
|
await channel.BasicConsumeAsync(
|
||||||
queue: _config.QueueName,
|
queue: _queueName,
|
||||||
autoAck: false,
|
autoAck: false,
|
||||||
consumer: consumer,
|
consumer: consumer,
|
||||||
cancellationToken: CnnFactory.CancellationToken);
|
cancellationToken: cnnFactory.CancellationToken);
|
||||||
|
|
||||||
logger?.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _config.QueueName);
|
logger?.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _queueName);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initialize RabbitMQ connection, channel, exchanges, and queues asynchronously.
|
/// Starts the consumer: opens a channel, sets QoS, and registers the event handler.
|
||||||
/// Start event-driven consumer that processes messages as they arrive
|
/// Called by <see cref="SendingEmailConsumerPool.InitAsync"/>.
|
||||||
/// Called lazily on first use via EnsureInitializedAsync.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task InitAsync()
|
public async Task InitAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages a pool of <see cref="SendingEmailConsumer"/> instances that compete for messages
|
||||||
|
/// on the same RabbitMQ queue (competing consumers pattern).
|
||||||
|
/// Each consumer owns a dedicated channel, so they process messages fully in parallel
|
||||||
|
/// without any shared locking or synchronization primitives.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SendingEmailConsumerPool : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly List<SendingEmailConsumer> _consumers;
|
||||||
|
private readonly ILogger<SendingEmailConsumerPool>? _logger;
|
||||||
|
private readonly int _concurrency;
|
||||||
|
|
||||||
|
public SendingEmailConsumerPool(
|
||||||
|
IOptions<RabbitMqConfiguration> config,
|
||||||
|
IEmailService emailService,
|
||||||
|
RabbitMqConnectionFactory cnnFactory,
|
||||||
|
ILogger<SendingEmailConsumerPool>? logger = null,
|
||||||
|
ILogger<SendingEmailConsumer>? consumerLogger = null)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_concurrency = config.Value.ConsumerConcurrency;
|
||||||
|
|
||||||
|
_consumers = [.. Enumerable
|
||||||
|
.Range(0, _concurrency)
|
||||||
|
.Select(_ => new SendingEmailConsumer(config.Value.QueueName, emailService, cnnFactory, consumerLogger))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts all consumers in parallel. Each consumer opens its own channel and begins listening.
|
||||||
|
/// </summary>
|
||||||
|
public async Task InitAsync()
|
||||||
|
{
|
||||||
|
_logger?.LogInformation("Starting {Count} competing email consumers.", _concurrency);
|
||||||
|
|
||||||
|
await Task.WhenAll(_consumers.Select(c => c.InitAsync()));
|
||||||
|
|
||||||
|
_logger?.LogInformation("All {Count} email consumers started.", _concurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Task.WhenAll(_consumers.Select(async c => await c.DisposeAsync().AsTask()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
|
||||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
using DigitalData.MessagingService.Infrastructure.Queue;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A hosted background service responsible for initializing the outgoing email queue consumer.
|
/// A hosted background service responsible for initializing the competing email consumer pool.
|
||||||
/// Leverages a push-based, event-driven RabbitMQ consumer to eliminate polling overhead.
|
/// Leverages a push-based, event-driven RabbitMQ consumer to eliminate polling overhead.
|
||||||
/// Email account configuration is resolved exclusively from application settings; no database access is performed.
|
/// Email account configuration is resolved exclusively from application settings; no database access is performed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AsyncInitWorker(SendingEmailConsumer EmailConsumer) : BackgroundService
|
public class AsyncInitWorker(SendingEmailConsumerPool ConsumerPool) : BackgroundService
|
||||||
{
|
{
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
await EmailConsumer.InitAsync();
|
await ConsumerPool.InitAsync();
|
||||||
|
|
||||||
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class LimilabsEmailService(
|
|||||||
public async Task SendEmailAsync(EmailContext context, CancellationToken cancellationToken = default)
|
public async Task SendEmailAsync(EmailContext context, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var smtp = new Smtp();
|
using var smtp = new Smtp();
|
||||||
|
ISendMessageResult? result = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender);
|
await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender);
|
||||||
@@ -48,25 +48,24 @@ public class LimilabsEmailService(
|
|||||||
|
|
||||||
var mail = builder.Create();
|
var mail = builder.Create();
|
||||||
|
|
||||||
var result = await smtp.SendMessageAsync(mail, cancellationToken);
|
result = await smtp.SendMessageAsync(mail, cancellationToken);
|
||||||
|
|
||||||
if (result.Status != SendMessageStatus.Success)
|
if (result.Status != SendMessageStatus.Success)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Failed to send email. Status: {result.Status}");
|
throw new InvalidOperationException($"Failed to send email. Status: {result.Status}. {ErrorMessageBuilder(result)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
await smtp.CloseAsync(cancellationToken);
|
await smtp.CloseAsync(cancellationToken);
|
||||||
await Task.CompletedTask; // For async consistency
|
|
||||||
}
|
}
|
||||||
catch (Limilabs.Client.ServerException ex)
|
catch (Limilabs.Client.ServerException ex)
|
||||||
{
|
{
|
||||||
await smtp.CloseSafelyAsync();
|
await smtp.CloseSafelyAsync();
|
||||||
throw new AuthenticationFailedException("SMTP authentication failed. Check credentials or OAuth2 configuration.", ex);
|
throw new AuthenticationFailedException($"SMTP authentication failed. Check credentials or OAuth2 configuration. {ErrorMessageBuilder(result)}", ex);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await smtp.CloseSafelyAsync();
|
await smtp.CloseSafelyAsync();
|
||||||
throw new InvalidOperationException("Failed to send email via SMTP server.", ex);
|
throw new InvalidOperationException($"Failed to send email via SMTP server. {ErrorMessageBuilder(result)}", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,4 +91,21 @@ public class LimilabsEmailService(
|
|||||||
await smtp.LoginAsync(smtpAccount.Username, password);
|
await smtp.LoginAsync(smtpAccount.Username, password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private static string ErrorMessageBuilder(ISendMessageResult? result = null)
|
||||||
|
{
|
||||||
|
if(result is null || result.GeneralErrors.Count == 0)
|
||||||
|
return string.Empty;
|
||||||
|
else if(result.GeneralErrors.Count == 1)
|
||||||
|
return $"Error: {result.GeneralErrors.FirstOrDefault()}";
|
||||||
|
|
||||||
|
var message = new StringBuilder("Errors:\n");
|
||||||
|
|
||||||
|
foreach (var error in result.GeneralErrors)
|
||||||
|
{
|
||||||
|
message.AppendLine($" • {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return message.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,5 +75,11 @@ namespace DigitalData.MessagingService.RabbitMQ
|
|||||||
/// Routing key used to bind <see cref="DlqQueueName"/> to <see cref="DlqExchangeName"/>.
|
/// Routing key used to bind <see cref="DlqQueueName"/> to <see cref="DlqExchangeName"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string DlqRoutingKey { get; set; } = "email.outbox.dlq";
|
public string DlqRoutingKey { get; set; } = "email.outbox.dlq";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of email messages processed concurrently by the consumer.
|
||||||
|
/// Maps directly to RabbitMQ prefetchCount. Recommended: 3–5.
|
||||||
|
/// </summary>
|
||||||
|
public ushort ConsumerConcurrency { get; set; } = 5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user