разные правки по беку
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
using Hangfire;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновая задача: создать следующую партицию списка по типу заявок, записать её в
|
||||
/// реестр и переключить на неё запись.
|
||||
///
|
||||
/// Блокировка захватывается ЗДЕСЬ, при каждом запуске (в т.ч. на каждой повторной
|
||||
/// попытке Hangfire), а не в момент постановки в очередь. Enqueue и фактическое
|
||||
/// выполнение — разные события во времени: задача может полежать в очереди, быть
|
||||
/// повторена автоматически после сбоя, в теории — перезапущена вручную из дашборда.
|
||||
/// Всё это заново становится тем же самым «выполнением» с точки зрения Hangfire, но
|
||||
/// не с точки зрения блокировки: settled только выполнение может её удержать, и
|
||||
/// проверять это надо на каждом фактическом входе в код, создающий список в
|
||||
/// SharePoint, а не один раз на входе в очередь. Иначе, к примеру, зависший на
|
||||
/// повторе процесс и новый независимый триггер (например, второй HTTP-запрос,
|
||||
/// поймавший тот же порог до переключения списка) синхронизировались бы неверно.
|
||||
///
|
||||
/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список
|
||||
/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя
|
||||
/// безопасен и не создаёт вторую партицию. Больше трёх попыток смысла нет — если
|
||||
/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire,
|
||||
/// а не молотить впустую.
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 3)]
|
||||
public sealed class CreateNextListPartitionJob(
|
||||
ApprovalRouteDbContext db,
|
||||
ListPartitioningLock provisioningLock,
|
||||
ListPartitionRegistry registry,
|
||||
ISharePointListProvisioner provisioner,
|
||||
IOptions<ListPartitioningOptions> options,
|
||||
ILogger<CreateNextListPartitionJob> logger)
|
||||
{
|
||||
public async Task RunAsync(int requestTypeId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Единственная настоящая защита от параллельного запуска: атомарный захват
|
||||
// флага в строке request_types. Проверка перед постановкой в очередь —
|
||||
// только чтобы не плодить лишние задачи; гонку разруливает именно здесь.
|
||||
if (!await provisioningLock.TryAcquireAsync(
|
||||
requestTypeId, options.Value.StaleLockAfter, cancellationToken))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Провижининг списка для типа заявок {RequestTypeId} уже идёт — задача пропущена.",
|
||||
requestTypeId);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var requestType = await db.RequestTypes
|
||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||
|
||||
if (requestType is null)
|
||||
{
|
||||
logger.LogWarning("Тип заявок {RequestTypeId} не найден — провижининг отменён.", requestTypeId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"У типа заявок {RequestTypeId} не настроен список SharePoint — провижининг отменён.",
|
||||
requestTypeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Задачу могли поставить раньше, чем реестр успел узнать про текущую
|
||||
// партицию (например, ручным запуском) — регистрируем её, иначе серия
|
||||
// осталась бы без своего первого номера.
|
||||
var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
|
||||
|
||||
var maxNumber = await db.RequestListPartitions
|
||||
.Where(x => x.RequestTypeId == requestTypeId)
|
||||
.MaxAsync(x => (int?)x.Number, cancellationToken) ?? current.Number;
|
||||
|
||||
var nextNumber = maxNumber + 1;
|
||||
|
||||
logger.LogInformation(
|
||||
"Создаю партицию №{Number} для типа заявок {RequestTypeId} по структуре {SourceListId}.",
|
||||
nextNumber, requestTypeId, requestType.ListId);
|
||||
|
||||
// Партиция всегда создаётся на том же сайте, что и текущая (см. комментарий
|
||||
// на RequestListPartition о недопустимости разъезда серии по сайтам) — сайт
|
||||
// берём из RequestType, а не откуда-либо ещё, так что расхождение
|
||||
// структурно невозможно.
|
||||
var created = await provisioner.CreateNextListPairAsync(
|
||||
requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken);
|
||||
|
||||
// Реестр и переключение записи — одной транзакцией: иначе можно остаться
|
||||
// с записанной в реестр партицией, в которую никто не пишет, или наоборот.
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
db.RequestListPartitions.Add(new RequestListPartition
|
||||
{
|
||||
RequestTypeId = requestTypeId,
|
||||
Number = nextNumber,
|
||||
ListId = created.ListId,
|
||||
Title = created.Title,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
requestType.ListId = created.ListId;
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Партиция №{Number} ({ListId}) создана, запись по типу заявок {RequestTypeId} переключена на неё.",
|
||||
nextNumber, created.ListId, requestTypeId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Снимаем блокировку и на успехе, и на исключении: иначе упавшая задача
|
||||
// держала бы тип заблокированным до истечения StaleLockAfter.
|
||||
await provisioningLock.ReleaseAsync(requestTypeId, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user