Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs
T
2026-08-17 15:33:54 +05:00

126 lines
7.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}