Updated `OutgoingEmailConsumer` and `OutgoingEmailPublisher` to use `GetDefaultConnectionAsync` instead of `GetConnectionAsync` for initializing RabbitMQ connections. Renamed `GetConnectionAsync` to `GetDefaultConnectionAsync` in `RabbitMqConnectionFactory` to improve clarity and align with naming conventions. Updated `InitAsync` in `RabbitMqConnectionFactory` to use the renamed method. These changes improve consistency, maintainability, and clarity in RabbitMQ connection management.
131 lines
6.0 KiB
C#
131 lines
6.0 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
|
using DigitalData.MessagingService.RabbitMQ;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using RabbitMQ.Client;
|
|
using RabbitMQ.Client.Events;
|
|
using DigitalData.MessagingService.Application.Common.Events;
|
|
|
|
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
|
|
|
/// <summary>
|
|
/// RabbitMQ-based email queue implementation for outgoing emails.
|
|
/// Provides message persistence, scalability, and reliability.
|
|
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
|
|
/// </summary>
|
|
public sealed class OutgoingEmailConsumer(IOptions<RabbitMqConfiguration> config, ILogger<OutgoingEmailConsumer> Logger, IEmailService EmailService, RabbitMqConnectionFactory CnnFactory) : IAsyncDisposable
|
|
{
|
|
private readonly RabbitMqConfiguration _config = config.Value;
|
|
private IChannel? _consumeChannel = null; // Dedicated channel for consuming
|
|
private readonly CancellationTokenSource _consumerCts = new(); // Independent lifetime from InitAsync token
|
|
|
|
/// <summary>
|
|
/// Initialize RabbitMQ connection, channel, exchanges, and queues asynchronously.
|
|
/// Called lazily on first use via EnsureInitializedAsync.
|
|
/// </summary>
|
|
public async Task InitAsync()
|
|
{
|
|
var connection = await CnnFactory.GetDefaultConnectionAsync();
|
|
|
|
var stoppingToken = CnnFactory.CancellationToken;
|
|
|
|
// Separate channels: IChannel is not thread-safe; one per role is best practice
|
|
_consumeChannel = await connection.CreateChannelAsync(cancellationToken: stoppingToken);
|
|
|
|
// Consumer uses its own CancellationToken independent of the startup token,
|
|
// so it keeps running after InitAsync completes or its token is cancelled.
|
|
// Link stoppingToken so the consumer stops when the host stops.
|
|
stoppingToken.Register(_consumerCts.Cancel);
|
|
await StartConsumerAsync(_consumeChannel, _consumerCts.Token);
|
|
|
|
Logger.LogInformation("RabbitMQ initialized successfully: Queue={QueueName}, DLQ={DlqQueueName}", _config.QueueName, _config.DlqQueueName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Start event-driven consumer that processes messages as they arrive
|
|
/// </summary>
|
|
private async Task StartConsumerAsync(IChannel consumeChannel, CancellationToken cancellationToken)
|
|
{
|
|
var consumer = new AsyncEventingBasicConsumer(consumeChannel);
|
|
|
|
consumer.ReceivedAsync += async (sender, args) =>
|
|
{
|
|
OutgoingEmailEvent? oMailEvent = null;
|
|
try
|
|
{
|
|
var json = Encoding.UTF8.GetString(args.Body.ToArray());
|
|
oMailEvent = JsonSerializer.Deserialize<OutgoingEmailEvent>(json);
|
|
|
|
if (oMailEvent is not null)
|
|
{
|
|
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
|
|
await EmailService.SendEmailAsync(
|
|
oMailEvent.Recipient,
|
|
oMailEvent.Subject,
|
|
oMailEvent.Body,
|
|
isHtml: oMailEvent.IsHtml);
|
|
|
|
// Acknowledge message after successful processing
|
|
await consumeChannel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
|
|
}
|
|
else
|
|
{
|
|
Logger.LogWarning("Failed to deserialize email message: DeliveryTag={DeliveryTag}", args.DeliveryTag);
|
|
await consumeChannel.BasicNackAsync(args.DeliveryTag, false, false, args.CancellationToken); // Don't requeue invalid messages
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Failed to process email [To={To}, Subject={Subject}] message: DeliveryTag={DeliveryTag}. Moving to DLQ (NO retry).", oMailEvent?.Recipient, oMailEvent?.Subject, args.DeliveryTag);
|
|
|
|
// TODO: Error Reporting Strategy
|
|
// Option 1: Separate RabbitMQ Queue (emailprofiler.errors)
|
|
// - Create EmailErrorReport entity { OutgoingEmailEventId, Exception, StackTrace, Timestamp, RetryAttempt }
|
|
// - Publish to error queue: await _errorQueue.EnqueueAsync(errorReport)
|
|
// - Separate worker processes error queue → Log to DB/File/External monitoring
|
|
//
|
|
// Option 2: Database Table (TBEMLP_ERROR_LOG)
|
|
// - Columns: ERROR_ID, OUTBOX_ID, ERROR_MESSAGE, STACK_TRACE, ERROR_DATE
|
|
// - Insert via IErrorLogRepository.CreateAsync(errorLog)
|
|
//
|
|
// Option 3: External Monitoring Service
|
|
// - Sentry: SentrySdk.CaptureException(ex)
|
|
// - Application Insights: _telemetryClient.TrackException(ex)
|
|
// - Elasticsearch: _elasticClient.IndexDocument(errorLog)
|
|
//
|
|
// Recommended: Option 1 (RabbitMQ Error Queue) + Option 2 (DB persistence)
|
|
// - Fast async error logging (non-blocking)
|
|
// - Persistent storage for audit
|
|
// - Real-time alerting via monitoring worker
|
|
|
|
// NO RETRY - All failures move directly to DLQ
|
|
await consumeChannel.BasicNackAsync(args.DeliveryTag, false, false, args.CancellationToken); // requeue=false → DLQ
|
|
}
|
|
};
|
|
|
|
// Start consuming messages (event-driven, non-blocking)
|
|
await consumeChannel.BasicConsumeAsync(
|
|
queue: _config.QueueName,
|
|
autoAck: false,
|
|
consumer: consumer,
|
|
cancellationToken: cancellationToken);
|
|
|
|
Logger.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _config.QueueName);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _consumerCts.CancelAsync();
|
|
_consumerCts.Dispose();
|
|
|
|
if (_consumeChannel is not null)
|
|
{
|
|
await _consumeChannel.CloseAsync();
|
|
await _consumeChannel.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
}
|