274 lines
10 KiB
C#
274 lines
10 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
/// <summary>
|
|
/// Background service that retries failed promotion attempts with exponential backoff.
|
|
///
|
|
/// When VigilCareClinical is unreachable in a split deployment, promotion fails and
|
|
/// the batch remains in Approved status. This service:
|
|
/// 1. Finds batches in Approved status that have a failed PromotionAttempt
|
|
/// with a NextRetryAt in the past.
|
|
/// 2. Retries promotion via IPromotionService.
|
|
/// 3. On failure, records a new PromotionAttempt with exponentially increasing NextRetryAt.
|
|
/// 4. After MaxRetryAttempts, sets NextRetryAt to null (manual intervention required)
|
|
/// and logs a critical warning.
|
|
///
|
|
/// The batch NEVER reverts to draft — it stays Approved until promotion succeeds
|
|
/// or an administrator manually intervenes.
|
|
/// </summary>
|
|
public class PromotionRetryService : BackgroundService
|
|
{
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly PromotionRetryOptions _options;
|
|
private readonly ILogger<PromotionRetryService> _logger;
|
|
|
|
public PromotionRetryService(
|
|
IServiceScopeFactory scopeFactory,
|
|
IOptions<PromotionRetryOptions> options,
|
|
ILogger<PromotionRetryService> logger)
|
|
{
|
|
_scopeFactory = scopeFactory;
|
|
_options = options.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
_logger.LogInformation(
|
|
"PromotionRetryService started. Poll interval: {PollInterval}s, " +
|
|
"max retries: {MaxRetries}, initial delay: {InitialDelay}s, " +
|
|
"max delay: {MaxDelay}s, backoff multiplier: {Multiplier}",
|
|
_options.PollIntervalSeconds,
|
|
_options.MaxRetryAttempts,
|
|
_options.InitialDelaySeconds,
|
|
_options.MaxDelaySeconds,
|
|
_options.BackoffMultiplier);
|
|
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
await ProcessPendingRetriesAsync(stoppingToken);
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex,
|
|
"PromotionRetryService encountered an error during processing");
|
|
}
|
|
|
|
await Task.Delay(
|
|
TimeSpan.FromSeconds(_options.PollIntervalSeconds), stoppingToken);
|
|
}
|
|
|
|
_logger.LogInformation("PromotionRetryService stopped");
|
|
}
|
|
|
|
private async Task ProcessPendingRetriesAsync(CancellationToken ct)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var promotionService = scope.ServiceProvider.GetRequiredService<IPromotionService>();
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
// Find batches that need retry:
|
|
// 1. Batch is in Approved status (not yet promoted)
|
|
// 2. Has a failed PromotionAttempt with NextRetryAt <= now
|
|
var pendingRetries = await db.PromotionAttempts
|
|
.Include(a => a.Batch)
|
|
.Where(a => !a.Succeeded
|
|
&& a.NextRetryAt != null
|
|
&& a.NextRetryAt <= now
|
|
&& a.Batch.Status == BatchStatus.Approved)
|
|
.OrderBy(a => a.NextRetryAt)
|
|
.Take(10) // Process up to 10 retries per poll cycle
|
|
.ToListAsync(ct);
|
|
|
|
if (pendingRetries.Count == 0)
|
|
return;
|
|
|
|
_logger.LogInformation(
|
|
"PromotionRetryService found {Count} batches pending retry",
|
|
pendingRetries.Count);
|
|
|
|
foreach (var attempt in pendingRetries)
|
|
{
|
|
await RetryPromotionAsync(db, promotionService, attempt, ct);
|
|
}
|
|
}
|
|
|
|
private async Task RetryPromotionAsync(
|
|
AppDbContext db,
|
|
IPromotionService promotionService,
|
|
PromotionAttempt lastAttempt,
|
|
CancellationToken ct)
|
|
{
|
|
var batchId = lastAttempt.BatchId;
|
|
var nextAttemptNumber = lastAttempt.AttemptNumber + 1;
|
|
|
|
_logger.LogInformation(
|
|
"Retrying promotion for batch {BatchId}, attempt {Attempt}/{MaxAttempts}",
|
|
batchId, nextAttemptNumber, _options.MaxRetryAttempts);
|
|
|
|
try
|
|
{
|
|
// Verify batch is still in Approved status
|
|
var batch = await db.DigitizationBatches.FindAsync(
|
|
new object[] { batchId }, ct);
|
|
|
|
if (batch is null || batch.Status != BatchStatus.Approved)
|
|
{
|
|
_logger.LogWarning(
|
|
"Batch {BatchId} is no longer in Approved status, skipping retry",
|
|
batchId);
|
|
return;
|
|
}
|
|
|
|
var approverUserId = batch.ApprovedByUserId
|
|
?? throw new InvalidOperationException(
|
|
$"Batch {batchId} is in Approved status but has no ApprovedByUserId");
|
|
|
|
// Attempt promotion with a unique idempotency key per attempt
|
|
await promotionService.PromoteAsync(batchId, approverUserId);
|
|
|
|
// --- Success path ---
|
|
var successAttempt = new PromotionAttempt
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
AttemptNumber = nextAttemptNumber,
|
|
Succeeded = true,
|
|
ErrorMessage = null,
|
|
AttemptedAt = DateTimeOffset.UtcNow,
|
|
NextRetryAt = null
|
|
};
|
|
|
|
db.PromotionAttempts.Add(successAttempt);
|
|
|
|
// Clear the previous attempt's NextRetryAt so it won't be picked up again
|
|
lastAttempt.NextRetryAt = null;
|
|
|
|
// Audit event
|
|
db.DigitizationEvents.Add(new DigitizationEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
EventType = DigitizationEventType.PromotionRetrySucceeded,
|
|
ActorUserId = approverUserId,
|
|
OccurredAt = DateTimeOffset.UtcNow,
|
|
MetadataJson = JsonSerializer.Serialize(new
|
|
{
|
|
attemptNumber = nextAttemptNumber,
|
|
retriedAt = DateTimeOffset.UtcNow
|
|
})
|
|
});
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
_logger.LogInformation(
|
|
"Batch {BatchId} successfully promoted on retry attempt {Attempt}",
|
|
batchId, nextAttemptNumber);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
throw; // Let the outer loop handle shutdown
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"Promotion retry failed for batch {BatchId}, attempt {Attempt}",
|
|
batchId, nextAttemptNumber);
|
|
|
|
// Calculate next retry delay with exponential backoff
|
|
var delay = CalculateBackoffDelay(nextAttemptNumber);
|
|
DateTimeOffset? nextRetryAt = null;
|
|
|
|
if (nextAttemptNumber < _options.MaxRetryAttempts)
|
|
{
|
|
nextRetryAt = DateTimeOffset.UtcNow.Add(delay);
|
|
|
|
_logger.LogInformation(
|
|
"Scheduling retry for batch {BatchId} at {NextRetryAt} " +
|
|
"(delay: {DelaySec:F0}s, attempt {Attempt}/{Max})",
|
|
batchId, nextRetryAt, delay.TotalSeconds,
|
|
nextAttemptNumber, _options.MaxRetryAttempts);
|
|
}
|
|
else
|
|
{
|
|
// Exhausted all retries — manual intervention required
|
|
_logger.LogCritical(
|
|
"Batch {BatchId} has exhausted all {Max} retry attempts. " +
|
|
"Manual intervention required. Last error: {Error}",
|
|
batchId, _options.MaxRetryAttempts, ex.Message);
|
|
}
|
|
|
|
// Record failed attempt
|
|
var failedAttempt = new PromotionAttempt
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
AttemptNumber = nextAttemptNumber,
|
|
Succeeded = false,
|
|
ErrorMessage = ex.Message.Length > 2000
|
|
? ex.Message[..2000]
|
|
: ex.Message,
|
|
AttemptedAt = DateTimeOffset.UtcNow,
|
|
NextRetryAt = nextRetryAt
|
|
};
|
|
|
|
db.PromotionAttempts.Add(failedAttempt);
|
|
|
|
// Clear the previous attempt's NextRetryAt
|
|
lastAttempt.NextRetryAt = null;
|
|
|
|
// Audit event
|
|
var batch = await db.DigitizationBatches.FindAsync(
|
|
new object[] { batchId }, ct);
|
|
var actorId = batch?.ApprovedByUserId ?? Guid.Empty;
|
|
|
|
db.DigitizationEvents.Add(new DigitizationEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
EventType = nextRetryAt.HasValue
|
|
? DigitizationEventType.PromotionRetryFailed
|
|
: DigitizationEventType.PromotionRetryExhausted,
|
|
ActorUserId = actorId,
|
|
OccurredAt = DateTimeOffset.UtcNow,
|
|
MetadataJson = JsonSerializer.Serialize(new
|
|
{
|
|
attemptNumber = nextAttemptNumber,
|
|
error = ex.Message,
|
|
nextRetryAt = nextRetryAt?.ToString("o"),
|
|
exhausted = !nextRetryAt.HasValue
|
|
})
|
|
});
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates exponential backoff delay for a given attempt number.
|
|
/// Formula: min(initialDelay * multiplier^(attempt-1), maxDelay)
|
|
/// With jitter: adds up to 10% random jitter to prevent thundering herd
|
|
/// when multiple replicas retry simultaneously.
|
|
/// </summary>
|
|
private TimeSpan CalculateBackoffDelay(int attemptNumber)
|
|
{
|
|
var baseDelay = _options.InitialDelaySeconds
|
|
* Math.Pow(_options.BackoffMultiplier, attemptNumber - 1);
|
|
|
|
var cappedDelay = Math.Min(baseDelay, _options.MaxDelaySeconds);
|
|
|
|
// Add up to 10% jitter
|
|
var jitter = cappedDelay * 0.1 * Random.Shared.NextDouble();
|
|
var finalDelay = cappedDelay + jitter;
|
|
|
|
return TimeSpan.FromSeconds(finalDelay);
|
|
}
|
|
} |