440 lines
16 KiB
C#
440 lines
16 KiB
C#
using System.Text.Json;
|
|
using Jiaowu.Api.Domain.System;
|
|
using RabbitMQ.Client;
|
|
using RabbitMQ.Client.Events;
|
|
|
|
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
|
|
|
public sealed class RabbitMqBackgroundJobTransport(
|
|
BackgroundJobOptions jobOptions,
|
|
RabbitMqOptions rabbitOptions,
|
|
ILogger<RabbitMqBackgroundJobTransport> logger)
|
|
: IBackgroundJobTransport, IAsyncDisposable
|
|
{
|
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
|
private IConnection? _connection;
|
|
private IChannel? _channel;
|
|
|
|
public bool IsDurable => true;
|
|
|
|
public async ValueTask PublishAsync(
|
|
BackgroundJobEnvelope message,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var body = JsonSerializer.SerializeToUtf8Bytes(message);
|
|
await _gate.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
var channel = await GetChannelAsync(cancellationToken);
|
|
var properties = new BasicProperties
|
|
{
|
|
Persistent = true,
|
|
ContentType = "application/json",
|
|
MessageId = message.OutboxMessageId.ToString("D"),
|
|
Type = message.JobKind.ToString(),
|
|
AppId = "jiaowu-api"
|
|
};
|
|
await channel.BasicPublishAsync(
|
|
jobOptions.Exchange,
|
|
RabbitMqBackgroundJobTopology.RoutingKey(message.JobKind),
|
|
mandatory: true,
|
|
properties,
|
|
body,
|
|
cancellationToken);
|
|
}
|
|
catch
|
|
{
|
|
await ResetConnectionAsync();
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
_gate.Release();
|
|
}
|
|
}
|
|
|
|
public async Task<bool> CheckHealthAsync(CancellationToken cancellationToken)
|
|
{
|
|
await _gate.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
var channel = await GetChannelAsync(cancellationToken);
|
|
return channel.IsOpen;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "RabbitMQ health check failed.");
|
|
await ResetConnectionAsync();
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
_gate.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<IChannel> GetChannelAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (_channel is { IsOpen: true })
|
|
return _channel;
|
|
|
|
await ResetConnectionAsync();
|
|
_connection = await RabbitMqBackgroundJobTopology.CreateConnectionAsync(
|
|
rabbitOptions,
|
|
"jiaowu-background-job-publisher",
|
|
consumerDispatchConcurrency: 1,
|
|
cancellationToken);
|
|
_channel = await _connection.CreateChannelAsync(
|
|
new CreateChannelOptions(
|
|
publisherConfirmationsEnabled: true,
|
|
publisherConfirmationTrackingEnabled: true),
|
|
cancellationToken);
|
|
await RabbitMqBackgroundJobTopology.DeclareAsync(
|
|
_channel,
|
|
jobOptions,
|
|
cancellationToken);
|
|
return _channel;
|
|
}
|
|
|
|
private async Task ResetConnectionAsync()
|
|
{
|
|
if (_channel is not null)
|
|
{
|
|
try
|
|
{
|
|
await _channel.DisposeAsync();
|
|
}
|
|
catch
|
|
{
|
|
// The broker may already have closed the channel.
|
|
}
|
|
_channel = null;
|
|
}
|
|
|
|
if (_connection is not null)
|
|
{
|
|
try
|
|
{
|
|
await _connection.DisposeAsync();
|
|
}
|
|
catch
|
|
{
|
|
// The broker may already have closed the connection.
|
|
}
|
|
_connection = null;
|
|
}
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _gate.WaitAsync();
|
|
try
|
|
{
|
|
await ResetConnectionAsync();
|
|
}
|
|
finally
|
|
{
|
|
_gate.Release();
|
|
_gate.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class RabbitMqBackgroundJobWorker(
|
|
BackgroundJobOptions jobOptions,
|
|
RabbitMqOptions rabbitOptions,
|
|
BackgroundJobRunner runner,
|
|
ILogger<RabbitMqBackgroundJobWorker> logger) : BackgroundService
|
|
{
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
IConnection? connection = null;
|
|
var channels = new List<IChannel>();
|
|
try
|
|
{
|
|
var consumerCount = RabbitMqBackgroundJobTopology.JobKinds.Sum(
|
|
jobOptions.ConsumerConcurrency);
|
|
connection = await RabbitMqBackgroundJobTopology.CreateConnectionAsync(
|
|
rabbitOptions,
|
|
"jiaowu-background-job-worker",
|
|
(ushort)consumerCount,
|
|
stoppingToken);
|
|
|
|
foreach (var kind in RabbitMqBackgroundJobTopology.JobKinds)
|
|
{
|
|
for (var workerIndex = 0;
|
|
workerIndex < jobOptions.ConsumerConcurrency(kind);
|
|
workerIndex++)
|
|
{
|
|
var channel = await connection.CreateChannelAsync(
|
|
new CreateChannelOptions(
|
|
publisherConfirmationsEnabled: false,
|
|
publisherConfirmationTrackingEnabled: false),
|
|
stoppingToken);
|
|
channels.Add(channel);
|
|
await RabbitMqBackgroundJobTopology.DeclareAsync(
|
|
channel,
|
|
jobOptions,
|
|
stoppingToken);
|
|
await channel.BasicQosAsync(
|
|
0,
|
|
jobOptions.PrefetchCount,
|
|
global: false,
|
|
stoppingToken);
|
|
|
|
var consumer = new AsyncEventingBasicConsumer(channel);
|
|
consumer.ReceivedAsync += async (_, eventArgs) =>
|
|
{
|
|
using var deliveryCancellation =
|
|
CancellationTokenSource.CreateLinkedTokenSource(
|
|
eventArgs.CancellationToken,
|
|
stoppingToken);
|
|
var deliveryToken = deliveryCancellation.Token;
|
|
try
|
|
{
|
|
var message = JsonSerializer
|
|
.Deserialize<BackgroundJobEnvelope>(
|
|
eventArgs.Body.Span);
|
|
if (message is null || message.JobKind != kind)
|
|
{
|
|
await channel.BasicNackAsync(
|
|
eventArgs.DeliveryTag,
|
|
multiple: false,
|
|
requeue: false,
|
|
deliveryToken);
|
|
return;
|
|
}
|
|
|
|
var result = await runner.RunAsync(
|
|
message,
|
|
deliveryToken);
|
|
if (result.Outcome ==
|
|
BackgroundJobRunOutcome.Completed)
|
|
{
|
|
await channel.BasicAckAsync(
|
|
eventArgs.DeliveryTag,
|
|
multiple: false,
|
|
deliveryToken);
|
|
return;
|
|
}
|
|
|
|
await Task.Delay(
|
|
result.RetryAfter,
|
|
deliveryToken);
|
|
await channel.BasicNackAsync(
|
|
eventArgs.DeliveryTag,
|
|
multiple: false,
|
|
requeue: true,
|
|
deliveryToken);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Closing the channel requeues unacknowledged deliveries.
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(
|
|
exception,
|
|
"RabbitMQ delivery for {JobKind} failed and will be requeued.",
|
|
kind);
|
|
if (!channel.IsOpen)
|
|
return;
|
|
|
|
await channel.BasicNackAsync(
|
|
eventArgs.DeliveryTag,
|
|
multiple: false,
|
|
requeue: true,
|
|
CancellationToken.None);
|
|
}
|
|
};
|
|
await channel.BasicConsumeAsync(
|
|
RabbitMqBackgroundJobTopology.QueueName(
|
|
jobOptions,
|
|
kind),
|
|
autoAck: false,
|
|
consumer,
|
|
stoppingToken);
|
|
}
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"{ConsumerCount} RabbitMQ background job consumers are connected to {HostName}:{Port}.",
|
|
consumerCount,
|
|
rabbitOptions.HostName,
|
|
rabbitOptions.Port);
|
|
while (connection.IsOpen && !stoppingToken.IsCancellationRequested)
|
|
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
break;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(
|
|
exception,
|
|
"RabbitMQ background job consumer connection failed; retrying.");
|
|
}
|
|
finally
|
|
{
|
|
foreach (var channel in channels)
|
|
{
|
|
try
|
|
{
|
|
await channel.DisposeAsync();
|
|
}
|
|
catch
|
|
{
|
|
// The connection may already have disposed its channels.
|
|
}
|
|
}
|
|
|
|
if (connection is not null)
|
|
{
|
|
try
|
|
{
|
|
await connection.DisposeAsync();
|
|
}
|
|
catch
|
|
{
|
|
// The broker may already have closed the connection.
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!stoppingToken.IsCancellationRequested)
|
|
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static class RabbitMqBackgroundJobTopology
|
|
{
|
|
public static readonly BackgroundJobKind[] JobKinds =
|
|
[
|
|
BackgroundJobKind.AutomaticSchedule,
|
|
BackgroundJobKind.SchedulePublish,
|
|
BackgroundJobKind.MakeupExamAuto,
|
|
BackgroundJobKind.ExamArrangement,
|
|
BackgroundJobKind.ExamSignInExport,
|
|
BackgroundJobKind.ExamPublish,
|
|
BackgroundJobKind.CourseGradeStatisticsRefresh
|
|
];
|
|
|
|
public static async Task<IConnection> CreateConnectionAsync(
|
|
RabbitMqOptions options,
|
|
string clientName,
|
|
ushort consumerDispatchConcurrency,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var factory = new ConnectionFactory
|
|
{
|
|
HostName = options.HostName,
|
|
Port = options.Port,
|
|
UserName = options.UserName,
|
|
Password = options.Password,
|
|
VirtualHost = options.VirtualHost,
|
|
ClientProvidedName = clientName,
|
|
AutomaticRecoveryEnabled = false,
|
|
TopologyRecoveryEnabled = false,
|
|
RequestedHeartbeat = TimeSpan.FromSeconds(30),
|
|
ConsumerDispatchConcurrency = consumerDispatchConcurrency
|
|
};
|
|
if (options.UseTls)
|
|
{
|
|
factory.Ssl = new SslOption
|
|
{
|
|
Enabled = true,
|
|
ServerName = string.IsNullOrWhiteSpace(options.TlsServerName)
|
|
? options.HostName
|
|
: options.TlsServerName
|
|
};
|
|
}
|
|
|
|
return await factory.CreateConnectionAsync(cancellationToken);
|
|
}
|
|
|
|
public static async Task DeclareAsync(
|
|
IChannel channel,
|
|
BackgroundJobOptions options,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var deadExchange = options.Exchange + ".dead";
|
|
await channel.ExchangeDeclareAsync(
|
|
options.Exchange,
|
|
ExchangeType.Direct,
|
|
durable: true,
|
|
autoDelete: false,
|
|
cancellationToken: cancellationToken);
|
|
await channel.ExchangeDeclareAsync(
|
|
deadExchange,
|
|
ExchangeType.Direct,
|
|
durable: true,
|
|
autoDelete: false,
|
|
cancellationToken: cancellationToken);
|
|
|
|
foreach (var kind in JobKinds)
|
|
{
|
|
var routingKey = RoutingKey(kind);
|
|
var queueName = QueueName(options, kind);
|
|
var deadQueueName = queueName + ".dead";
|
|
var queueArguments = QueueArguments(options);
|
|
queueArguments["x-dead-letter-exchange"] = deadExchange;
|
|
queueArguments["x-dead-letter-routing-key"] = routingKey;
|
|
await channel.QueueDeclareAsync(
|
|
queueName,
|
|
durable: true,
|
|
exclusive: false,
|
|
autoDelete: false,
|
|
arguments: queueArguments,
|
|
cancellationToken: cancellationToken);
|
|
await channel.QueueBindAsync(
|
|
queueName,
|
|
options.Exchange,
|
|
routingKey,
|
|
cancellationToken: cancellationToken);
|
|
|
|
await channel.QueueDeclareAsync(
|
|
deadQueueName,
|
|
durable: true,
|
|
exclusive: false,
|
|
autoDelete: false,
|
|
arguments: QueueArguments(options),
|
|
cancellationToken: cancellationToken);
|
|
await channel.QueueBindAsync(
|
|
deadQueueName,
|
|
deadExchange,
|
|
routingKey,
|
|
cancellationToken: cancellationToken);
|
|
}
|
|
}
|
|
|
|
public static string RoutingKey(BackgroundJobKind kind) => kind switch
|
|
{
|
|
BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
|
|
BackgroundJobKind.SchedulePublish => "schedule.publish",
|
|
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
|
|
BackgroundJobKind.ExamArrangement => "exam.arrangement",
|
|
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
|
|
BackgroundJobKind.ExamPublish => "exam.publish",
|
|
BackgroundJobKind.CourseGradeStatisticsRefresh => "grade-statistics.refresh",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
|
};
|
|
|
|
public static string QueueName(
|
|
BackgroundJobOptions options,
|
|
BackgroundJobKind kind) =>
|
|
$"{options.QueuePrefix}.{RoutingKey(kind)}";
|
|
|
|
private static Dictionary<string, object?> QueueArguments(
|
|
BackgroundJobOptions options)
|
|
{
|
|
var arguments = new Dictionary<string, object?>();
|
|
if (options.UseQuorumQueues)
|
|
arguments["x-queue-type"] = "quorum";
|
|
return arguments;
|
|
}
|
|
}
|