причесывание

This commit is contained in:
gorbenko.pavel
2026-08-19 09:44:31 +05:00
parent 1dff7c7112
commit 58c27b2aa5
5 changed files with 239 additions and 64 deletions
@@ -3,7 +3,6 @@ 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;
@@ -72,16 +71,13 @@ public sealed class CreateNextListPartitionJob(
return;
}
// Задачу могли поставить раньше, чем реестр успел узнать про текущую
// партицию (например, ручным запуском) — регистрируем её, иначе серия
// осталась бы без своего первого номера.
var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
// Партиция, на которую сейчас указывает RequestType.ListId, прямо сейчас
// перестаёт быть текущей — записываем её в архив (request_list_partitions
// хранит только архив, см. ListPartitionRegistry). До этого момента она
// нигде отдельной строкой не значилась.
var archivedCurrent = await registry.ArchiveCurrentAsync(requestType, cancellationToken);
var maxNumber = await db.RequestListPartitions
.Where(x => x.RequestTypeId == requestTypeId)
.MaxAsync(x => (int?)x.Number, cancellationToken) ?? current.Number;
var nextNumber = maxNumber + 1;
var nextNumber = archivedCurrent.Number + 1;
logger.LogInformation(
"Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " +
@@ -113,23 +109,12 @@ public sealed class CreateNextListPartitionJob(
throw;
}
// Реестр и переключение записи — одной транзакцией: иначе можно остаться
// с записанной в реестр партицией, в которую никто не пишет, или наоборот.
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
});
// Новая партиция строкой в request_list_partitions пока не появляется —
// она получит свою запись только когда САМА станет архивом, следующим
// вызовом этой же задачи (см. doc-комментарий класса ListPartitionRegistry).
// Здесь переключаем только указатель.
requestType.ListId = created.ListId;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation(
"Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " +
@@ -8,8 +8,14 @@ namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
/// <summary>
/// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему
/// эндпоинту, и фоновой задаче: реестр появился позже самих списков, и любой из них
/// может встретить тип заявок, которого в реестре ещё нет.
/// эндпоинту, и фоновой задаче.
///
/// request_list_partitions хранит ТОЛЬКО архив — партиции, которые уже перестали
/// быть текущими. Текущая партиция нигде отдельной строкой не пишется: она всегда
/// синтезируется на лету из RequestType.ListId/SiteUrl (см. BuildCurrentAsync).
/// Иначе один и тот же список оказывался бы записан дважды — и в RequestType.ListId,
/// и строкой в этой таблице — сразу же, при первом же чтении, до того как партиция
/// реально стала архивом.
/// </summary>
public sealed class ListPartitionRegistry(
ApprovalRouteDbContext db,
@@ -17,16 +23,61 @@ public sealed class ListPartitionRegistry(
ILogger<ListPartitionRegistry> logger)
{
/// <summary>
/// Гарантирует, что текущая партиция типа заявок есть в реестре, и возвращает её строку.
/// Партиции, которые УЖЕ архив — всё, что реестр знает о серии, кроме текущей.
///
/// Реестр самозаполняющийся: до этой фичи списки жили только в
/// RequestType.ListId, и переключить список руками в админке можно и сейчас.
/// Поэтому неизвестный реестру ListId дописывается следующим номером, а не
/// считается ошибкой.
///
/// Требует заполненного ListId — вызывающая сторона это проверяет.
/// Фильтр по ListId != RequestType.ListId — не оптимизация, а самовосстановление
/// на случай узкого окна в ArchiveCurrentAsync (см. её комментарий): если старая
/// партиция уже успела записаться архивом, а переключение указателя на новую ещё
/// не прошло (SharePoint упал между этими двумя шагами), без фильтра она была бы
/// видна СРАЗУ в обоих качествах — и как архив, и как текущая (см.
/// BuildCurrentAsync). Фильтр держит текущую ровно в одном месте, даже в этом
/// переходном состоянии.
/// </summary>
public async Task<RequestListPartition> EnsureCurrentRegisteredAsync(
public async Task<IReadOnlyList<RequestListPartition>> GetArchivedAsync(
RequestType requestType, CancellationToken cancellationToken) =>
await db.RequestListPartitions.AsNoTracking()
.Where(x => x.RequestTypeId == requestType.Id && x.ListId != requestType.ListId)
.OrderBy(x => x.Number)
.ToListAsync(cancellationToken);
/// <summary>
/// Синтезирует запись о ТЕКУЩЕЙ партиции — без обращения к request_list_partitions
/// на запись: строка появится там только когда эта партиция реально станет
/// архивом (см. ArchiveCurrentAsync). Номер — следующий за последним архивным
/// (или 1, если архива ещё нет); Title читается REST'ом каждый раз, а не
/// кешируется — кешировать было бы негде без строки в БД.
///
/// archived — уже загруженный список архивных партиций этого типа (см.
/// GetArchivedAsync), передаётся, а не читается заново, чтобы вызывающая
/// сторона (которой archived обычно уже нужен для остальной серии) не делала
/// один и тот же запрос дважды.
/// </summary>
public async Task<RequestListPartitionDto> BuildCurrentAsync(
RequestType requestType, IReadOnlyList<RequestListPartition> archived, CancellationToken cancellationToken)
{
var number = archived.Count == 0 ? 1 : archived.Max(x => x.Number) + 1;
var title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken);
return new RequestListPartitionDto(
number, requestType.SiteUrl!, requestType.ListId!, title, IsCurrent: true, DateTimeOffset.UtcNow);
}
/// <summary>
/// Записывает ТЕКУЩУЮ партицию как архивную строку — вызывать строго в момент,
/// когда она перестаёт быть текущей (единственный вызывающий —
/// CreateNextListPartitionJob, прямо перед тем как переключить
/// RequestType.ListId на следующую партицию).
///
/// Читает свой собственный, НЕфильтрованный список существующих партиций —
/// намеренно не переиспользует GetArchivedAsync(): та фильтрует по
/// ListId != RequestType.ListId, и ровно между этим вызовом и переключением
/// указателя (см. CreateNextListPartitionJob) только что записанная строка
/// на мгновение совпадает с текущим ListId — фильтр GetArchivedAsync её бы не
/// увидел, идемпотентность сломалась бы, и повтор Hangfire упал бы на
/// уникальном индексе (request_type_id, number) вместо того, чтобы найти уже
/// записанное и вернуть его.
/// </summary>
public async Task<RequestListPartition> ArchiveCurrentAsync(
RequestType requestType, CancellationToken cancellationToken)
{
var series = await db.RequestListPartitions
@@ -49,14 +100,14 @@ public sealed class ListPartitionRegistry(
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Список {ListId} типа заявок {RequestTypeId} добавлен в реестр под номером {Number}.",
"Список {ListId} типа заявок {RequestTypeId} заархивирован под номером {Number}.",
entity.ListId, requestType.Id, entity.Number);
return entity;
}
// Заголовок нужен только для отображения в админке/портале — из-за его
// отсутствия регистрация партиции падать не должна.
// отсутствия чтение партиции падать не должно.
private async Task<string?> TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken)
{
try
@@ -44,7 +44,11 @@ public sealed class ListPartitionService(
return Result.Fail<CurrentListPartitionDto>(new ValidationError(
$"У типа заявки {requestTypeId} не настроен список SharePoint."));
var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
// Синтезируется на лету, не пишется в request_list_partitions — см.
// ListPartitionRegistry.BuildCurrentAsync: строка появится там только когда
// партиция реально станет архивом.
var archived = await registry.GetArchivedAsync(requestType, cancellationToken);
var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken);
var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken);
var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken);
@@ -96,14 +100,15 @@ public sealed class ListPartitionService(
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
return [];
await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
var archived = await registry.GetArchivedAsync(requestType, cancellationToken);
var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken);
return await db.RequestListPartitions.AsNoTracking()
.Where(x => x.RequestTypeId == requestType.Id)
.OrderBy(x => x.Number)
.Select(x => new RequestListPartitionDto(
x.Number, requestType.SiteUrl!, x.ListId, x.Title, x.ListId == requestType.ListId, x.CreatedAt))
.ToListAsync(cancellationToken);
var result = new List<RequestListPartitionDto>(archived.Count + 1);
result.AddRange(archived.Select(x =>
new RequestListPartitionDto(x.Number, requestType.SiteUrl!, x.ListId, x.Title, IsCurrent: false, x.CreatedAt)));
result.Add(current);
return result;
}
/// <summary>