@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
#nullable disable
using System.Text.Json ;
using System.Collections.Concurrent ;
using Azure.Storage.Queues ;
using Azure.Storage.Queues.Models ;
using Bit.Core.Models.Mail ;
@ -11,6 +12,11 @@ using Bit.Core.Utilities;
@@ -11,6 +12,11 @@ using Bit.Core.Utilities;
namespace Bit.Admin.HostedServices ;
public record FailedMailMessage ( MailQueueMessage Message , int RetryCount )
{
public DateTime LastAttemptTime { get ; init ; } = default ;
} ;
public class AzureQueueMailHostedService : IHostedService
{
private readonly ILogger < AzureQueueMailHostedService > _l ogger ;
@ -20,9 +26,9 @@ public class AzureQueueMailHostedService : IHostedService
@@ -20,9 +26,9 @@ public class AzureQueueMailHostedService : IHostedService
private Task _ executingTask ;
private QueueClient _ mailQueueClient ;
private const int MaxRetryAttempts = 3 ;
private readonly TimeSpan [ ] RetryDelays = { TimeSpan . FromSeconds ( 1 ) , TimeSpan . FromSeconds ( 5 ) , TimeSpan . FromSeconds ( 1 5 ) } ;
private readonly ConcurrentQueue < FailedMailMessage > _f ailedMessages = new ( ) ;
private readonly TimeSpan [ ] RetryDelays = { TimeSpan . FromSeconds ( 1 ) , TimeSpan . FromSeconds ( 5 ) , TimeSpan . FromSeconds ( 9 ) } ;
private Task _f ailedMessageProcessingTask ;
public AzureQueueMailHostedService (
ILogger < AzureQueueMailHostedService > logger ,
@ -38,7 +44,9 @@ public class AzureQueueMailHostedService : IHostedService
@@ -38,7 +44,9 @@ public class AzureQueueMailHostedService : IHostedService
{
_ cts = CancellationTokenSource . CreateLinkedTokenSource ( cancellationToken ) ;
_ executingTask = ExecuteAsync ( _ cts . Token ) ;
return _ executingTask . IsCompleted ? _ executingTask : Task . CompletedTask ;
_f ailedMessageProcessingTask = ProcessFailedMessagesBackgroundAsync ( _ cts . Token ) ;
return Task . WhenAny ( _ executingTask , _f ailedMessageProcessingTask ) . IsCompleted ?
Task . WhenAll ( _ executingTask , _f ailedMessageProcessingTask ) : Task . CompletedTask ;
}
public async Task StopAsync ( CancellationToken cancellationToken )
@ -48,7 +56,14 @@ public class AzureQueueMailHostedService : IHostedService
@@ -48,7 +56,14 @@ public class AzureQueueMailHostedService : IHostedService
return ;
}
_ cts . Cancel ( ) ;
await Task . WhenAny ( _ executingTask , Task . Delay ( - 1 , cancellationToken ) ) ;
var tasksToWait = new List < Task > { _ executingTask } ;
if ( _f ailedMessageProcessingTask ! = null )
{
tasksToWait . Add ( _f ailedMessageProcessingTask ) ;
}
await Task . WhenAny ( Task . WhenAll ( tasksToWait ) , Task . Delay ( - 1 , cancellationToken ) ) ;
cancellationToken . ThrowIfCancellationRequested ( ) ;
}
@ -56,80 +71,168 @@ public class AzureQueueMailHostedService : IHostedService
@@ -56,80 +71,168 @@ public class AzureQueueMailHostedService : IHostedService
{
_ mailQueueClient = new QueueClient ( _ globalSettings . Mail . ConnectionString , "mail" ) ;
QueueMessage [ ] mailMessages ;
while ( ! cancellationToken . IsCancellationRequested )
{
if ( ! ( mailMessages = await RetrieveMessagesAsync ( ) ) . Any ( ) )
var mailMessages = await RetrieveMessagesAsync ( ) ;
if ( ! mailMessages . Any ( ) )
{
await Task . Delay ( TimeSpan . FromSeconds ( 1 5 ) ) ;
await Task . Delay ( TimeSpan . FromSeconds ( 1 5 ) , cancellationToken ) ;
continue ;
}
foreach ( var message in mailMessages )
// Process all messages concurrently
var processingTasks = mailMessages . Select ( message = > ProcessMessageAsync ( message , cancellationToken ) ) ;
await Task . WhenAll ( processingTasks ) ;
}
}
private async Task ProcessMessageAsync ( QueueMessage message , CancellationToken cancellationToken )
{
try
{
using var document = JsonDocument . Parse ( message . DecodeMessageText ( ) ) ;
var root = document . RootElement ;
var mailMessages = new List < MailQueueMessage > ( ) ;
if ( root . ValueKind = = JsonValueKind . Array )
{
mailMessages . AddRange ( root . Deserialize < List < MailQueueMessage > > ( ) ) ;
}
else if ( root . ValueKind = = JsonValueKind . Object )
{
var success = await ProcessMessageWithRetryAsync ( message , cancellationToken ) ;
mailMessages . Add ( root . Deserialize < MailQueueMessage > ( ) ) ;
}
if ( success | | cancellationToken . IsCancellationRequested )
// Try to send each individual mail message
var failedMessages = new List < MailQueueMessage > ( ) ;
foreach ( var mailMessage in mailMessages )
{
try
{
await _ mailQueueClient . DeleteMessageAsync ( message . MessageId , message . PopReceipt ) ;
await _ mailService . SendEnqueuedMailMessageAsync ( mailMessage ) ;
}
catch ( Exception e )
{
_l ogger . LogWarning ( e , "Failed to send individual email message. Will be re-enqueued for retry." ) ;
failedMessages . Add ( mailMessage ) ;
}
}
if ( cancellationToken . IsCancellationRequested )
// If all messages succeeded, delete the original message
if ( ! failedMessages . Any ( ) )
{
await _ mailQueueClient . DeleteMessageAsync ( message . MessageId , message . PopReceipt ) ;
_l ogger . LogInformation ( "Successfully processed all email messages in batch" ) ;
}
else
{
// Queue failed messages for re-enqueuing
foreach ( var failedMessage in failedMessages )
{
break ;
_f ailedMessages . Enqueue ( new FailedMailMessage ( failedMessage , 0 ) ) ;
}
// Delete the original message since we've extracted individual failures
await _ mailQueueClient . DeleteMessageAsync ( message . MessageId , message . PopReceipt ) ;
_l ogger . LogInformation ( "Processed batch with {SuccessCount} successful and {FailedCount} failed messages" ,
mailMessages . Count - failedMessages . Count , failedMessages . Count ) ;
}
}
catch ( Exception e )
{
_l ogger . LogError ( e , "Failed to parse or process queue message. Message will be left in queue for retry." ) ;
}
}
private async Task < bool > ProcessMessageWithRetryAsync ( QueueMessage message , CancellationToken cancellationToken )
private async Task ProcessFailedMessagesBackgroundAsync ( CancellationToken cancellationToken )
{
for ( int attempt = 0 ; attempt < MaxRetryAttempts ; attempt + + )
const int maxRetryAttempts = 3 ;
const int pollingIntervalSeconds = 5 ;
while ( ! cancellationToken . IsCancellationRequested )
{
try
{
using var document = JsonDocument . Parse ( message . DecodeMessageText ( ) ) ;
var root = document . RootElement ;
var messagesToRequeue = new List < FailedMailMessage > ( ) ;
var processedCount = 0 ;
if ( root . ValueKind = = JsonValueKind . Array )
// Process failed messages in batches to avoid overwhelming the system
while ( _f ailedMessages . TryDequeue ( out var failedMessage ) & & processedCount < 1 0 )
{
foreach ( var mailQueueMessage in root . Deserialize < List < MailQueueMessage > > ( ) )
processedCount + + ;
// Calculate when this message should be retried based on its retry count
var nextRetryTime = CalculateNextRetryTime ( failedMessage ) ;
if ( DateTime . UtcNow < nextRetryTime )
{
// Message is not ready for retry yet, put it back for later
messagesToRequeue . Add ( failedMessage ) ;
continue ;
}
try
{
await _ mailService . SendEnqueuedMailMessageAsync ( failedMessage . Message ) ;
_l ogger . LogInformation ( "Successfully sent previously failed email message on retry attempt {RetryCount}" ,
failedMessage . RetryCount + 1 ) ;
}
catch ( Exception e )
{
await _ mailService . SendEnqueuedMailMessageAsync ( mailQueueMessage ) ;
var newRetryCount = failedMessage . RetryCount + 1 ;
if ( newRetryCount < maxRetryAttempts )
{
_l ogger . LogWarning ( e , "Failed to send email on retry attempt {RetryCount}/{MaxRetryAttempts}. Will retry later." ,
newRetryCount , maxRetryAttempts ) ;
messagesToRequeue . Add ( new FailedMailMessage ( failedMessage . Message , newRetryCount )
{
LastAttemptTime = DateTime . UtcNow
} ) ;
}
else
{
_l ogger . LogError ( e , "Failed to send email after {MaxRetryAttempts} retry attempts. Message will be permanently discarded." ,
maxRetryAttempts ) ;
}
}
}
else if ( root . ValueKind = = JsonValueKind . Object )
// Put messages that need more processing back into the queue
foreach ( var messageToRequeue in messagesToRequeue )
{
var mailQueueMessage = root . Deserialize < MailQueueMessage > ( ) ;
await _ mailService . SendEnqueuedMailMessageAsync ( mailQueueMessage ) ;
_f ailedMessages . Enqueue ( messageToRequeue ) ;
}
_l ogger . LogInformation ( "Successfully sent email message after {Attempt} attempts" , attempt + 1 ) ;
return true ;
}
catch ( Exception e )
{
var isLastAttempt = attempt = = MaxRetryAttempts - 1 ;
if ( isLastAttempt )
if ( processedCount > 0 )
{
_l ogger . LogError ( e , "Failed to send email after {MaxAttempts} attempts. Message will be deleted from queue." , MaxRetryAttempts ) ;
_l ogger . LogInformation ( "Background processor handled {ProcessedCount} failed messages" , processedCount ) ;
}
else
{
_l ogger . LogWarning ( e , "Failed to send email on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms" ,
attempt + 1 , MaxRetryAttempts , RetryDelays [ attempt ] . TotalMilliseconds ) ;
await Task . Delay ( RetryDelays [ attempt ] , cancellationToken ) ;
if ( cancellationToken . IsCancellationRequested )
{
return false ;
}
}
// Wait before next polling cycle
await Task . Delay ( TimeSpan . FromSeconds ( pollingIntervalSeconds ) , cancellationToken ) ;
}
catch ( Exception e ) when ( ! cancellationToken . IsCancellationRequested )
{
_l ogger . LogError ( e , "Error in background failed message processor. Will retry in {IntervalSeconds}s." , pollingIntervalSeconds ) ;
await Task . Delay ( TimeSpan . FromSeconds ( pollingIntervalSeconds ) , cancellationToken ) ;
}
}
}
private DateTime CalculateNextRetryTime ( FailedMailMessage failedMessage )
{
if ( failedMessage . RetryCount = = 0 | | failedMessage . LastAttemptTime = = default )
{
return DateTime . UtcNow ; // First attempt or no previous attempt time
}
return false ;
var delayIndex = Math . Min ( failedMessage . RetryCount - 1 , RetryDelays . Length - 1 ) ;
var delay = RetryDelays [ delayIndex ] ;
return failedMessage . LastAttemptTime . Add ( delay ) ;
}
private async Task < QueueMessage [ ] > RetrieveMessagesAsync ( )