149 lines
9.7 KiB
C#
149 lines
9.7 KiB
C#
using System.Diagnostics;
|
||
using Hangfire;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Options;
|
||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||
|
||
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,
|
||
TnsuPortalCallerToken callerToken,
|
||
IOptions<ListPartitioningOptions> options,
|
||
ILogger<CreateNextListPartitionJob> logger)
|
||
{
|
||
/// <param name="authorization">
|
||
/// Заголовок Authorization пользователя, чей запрос вызвал провижининг (см.
|
||
/// ListPartitionService.TryEnqueueProvisioningAsync). Нужен, чтобы подписать
|
||
/// новый список на уведомления от имени этого пользователя: своего HttpContext
|
||
/// у фоновой задачи нет, а client_credentials сейчас не настроен (см.
|
||
/// TnsuPortalAuthorizer). Провижининг занимает секунды, поэтому токен не
|
||
/// успевает истечь — но на ПОВТОРЕ Hangfire (минуты спустя) он уже может быть
|
||
/// просроченным: тогда подписка вернёт 401 и упадёт мягко, не сорвав создание
|
||
/// списка (см. ISharePointWebhookSubscriber).
|
||
///
|
||
/// Учтите: как аргумент задачи он попадает в хранилище Hangfire (Postgres) и
|
||
/// виден в его дашборде.
|
||
/// </param>
|
||
public async Task RunAsync(int requestTypeId, string? authorization, CancellationToken cancellationToken)
|
||
{
|
||
callerToken.Set(authorization);
|
||
|
||
// Единственная настоящая защита от параллельного запуска: атомарный захват
|
||
// флага в строке 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;
|
||
}
|
||
|
||
// Партиция, на которую сейчас указывает RequestType.ListId, прямо сейчас
|
||
// перестаёт быть текущей — записываем её в архив (request_list_partitions
|
||
// хранит только архив, см. ListPartitionRegistry). До этого момента она
|
||
// нигде отдельной строкой не значилась.
|
||
var archivedCurrent = await registry.ArchiveCurrentAsync(requestType, cancellationToken);
|
||
|
||
var nextNumber = archivedCurrent.Number + 1;
|
||
|
||
logger.LogInformation(
|
||
"Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " +
|
||
"по структуре {SourceListId}.",
|
||
nextNumber, requestTypeId, requestType.Name, requestType.ListId);
|
||
|
||
var stopwatch = Stopwatch.StartNew();
|
||
ProvisionedList created;
|
||
try
|
||
{
|
||
// Партиция всегда создаётся на том же сайте, что и текущая (см.
|
||
// комментарий на RequestListPartition о недопустимости разъезда серии
|
||
// по сайтам) — сайт берём из RequestType, а не откуда-либо ещё, так что
|
||
// расхождение структурно невозможно.
|
||
created = await provisioner.CreateNextListPairAsync(
|
||
requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Логируем с контекстом (номер партиции, тип заявок) здесь, а не
|
||
// полагаемся только на то, что исключение долетит до лога Hangfire:
|
||
// там оно будет без этих подробностей, вперемешку с логами других задач.
|
||
// Дальше даём ему пойти в Hangfire как обычно — так сработает
|
||
// AutomaticRetry(Attempts = 3).
|
||
logger.LogError(
|
||
ex,
|
||
"Провижининг партиции №{Number} для типа заявок {RequestTypeId} не удался за {ElapsedMs} мс.",
|
||
nextNumber, requestTypeId, stopwatch.ElapsedMilliseconds);
|
||
throw;
|
||
}
|
||
|
||
// Новая партиция строкой в request_list_partitions пока не появляется —
|
||
// она получит свою запись только когда САМА станет архивом, следующим
|
||
// вызовом этой же задачи (см. doc-комментарий класса ListPartitionRegistry).
|
||
// Здесь переключаем только указатель.
|
||
requestType.ListId = created.ListId;
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
|
||
logger.LogInformation(
|
||
"Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " +
|
||
"{RequestTypeId} переключена на неё.",
|
||
nextNumber, created.ListId, stopwatch.ElapsedMilliseconds, requestTypeId);
|
||
}
|
||
finally
|
||
{
|
||
// Снимаем блокировку и на успехе, и на исключении: иначе упавшая задача
|
||
// держала бы тип заблокированным до истечения StaleLockAfter.
|
||
await provisioningLock.ReleaseAsync(requestTypeId, CancellationToken.None);
|
||
}
|
||
}
|
||
}
|