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; /// /// Фоновая задача: создать следующую партицию списка по типу заявок, записать её в /// реестр и переключить на неё запись. /// /// Блокировка захватывается ЗДЕСЬ, при каждом запуске (в т.ч. на каждой повторной /// попытке Hangfire), а не в момент постановки в очередь. Enqueue и фактическое /// выполнение — разные события во времени: задача может полежать в очереди, быть /// повторена автоматически после сбоя, в теории — перезапущена вручную из дашборда. /// Всё это заново становится тем же самым «выполнением» с точки зрения Hangfire, но /// не с точки зрения блокировки: settled только выполнение может её удержать, и /// проверять это надо на каждом фактическом входе в код, создающий список в /// SharePoint, а не один раз на входе в очередь. Иначе, к примеру, зависший на /// повторе процесс и новый независимый триггер (например, второй HTTP-запрос, /// поймавший тот же порог до переключения списка) синхронизировались бы неверно. /// /// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список /// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя /// безопасен и не создаёт вторую партицию. Больше трёх попыток смысла нет — если /// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire, /// а не молотить впустую. /// [AutomaticRetry(Attempts = 3)] public sealed class CreateNextListPartitionJob( ApprovalRouteDbContext db, ListPartitioningLock provisioningLock, ListPartitionRegistry registry, ISharePointListProvisioner provisioner, TnsuPortalCallerToken callerToken, IOptions options, ILogger logger) { /// /// Заголовок Authorization пользователя, чей запрос вызвал провижининг (см. /// ListPartitionService.TryEnqueueProvisioningAsync). Нужен, чтобы подписать /// новый список на уведомления от имени этого пользователя: своего HttpContext /// у фоновой задачи нет, а client_credentials сейчас не настроен (см. /// TnsuPortalAuthorizer). Провижининг занимает секунды, поэтому токен не /// успевает истечь — но на ПОВТОРЕ Hangfire (минуты спустя) он уже может быть /// просроченным: тогда подписка вернёт 401 и упадёт мягко, не сорвав создание /// списка (см. ISharePointWebhookSubscriber). /// /// Учтите: как аргумент задачи он попадает в хранилище Hangfire (Postgres) и /// виден в его дашборде. /// 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); } } }