diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs index c3a4711..9c6d1dc 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs @@ -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} мс, запись по типу заявок " + diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs index d277ffd..d7578b6 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs @@ -8,8 +8,14 @@ namespace Tnsu.ApprovalRoute.Application.ListPartitioning; /// /// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему -/// эндпоинту, и фоновой задаче: реестр появился позже самих списков, и любой из них -/// может встретить тип заявок, которого в реестре ещё нет. +/// эндпоинту, и фоновой задаче. +/// +/// request_list_partitions хранит ТОЛЬКО архив — партиции, которые уже перестали +/// быть текущими. Текущая партиция нигде отдельной строкой не пишется: она всегда +/// синтезируется на лету из RequestType.ListId/SiteUrl (см. BuildCurrentAsync). +/// Иначе один и тот же список оказывался бы записан дважды — и в RequestType.ListId, +/// и строкой в этой таблице — сразу же, при первом же чтении, до того как партиция +/// реально стала архивом. /// public sealed class ListPartitionRegistry( ApprovalRouteDbContext db, @@ -17,16 +23,61 @@ public sealed class ListPartitionRegistry( ILogger logger) { /// - /// Гарантирует, что текущая партиция типа заявок есть в реестре, и возвращает её строку. + /// Партиции, которые УЖЕ архив — всё, что реестр знает о серии, кроме текущей. /// - /// Реестр самозаполняющийся: до этой фичи списки жили только в - /// RequestType.ListId, и переключить список руками в админке можно и сейчас. - /// Поэтому неизвестный реестру ListId дописывается следующим номером, а не - /// считается ошибкой. - /// - /// Требует заполненного ListId — вызывающая сторона это проверяет. + /// Фильтр по ListId != RequestType.ListId — не оптимизация, а самовосстановление + /// на случай узкого окна в ArchiveCurrentAsync (см. её комментарий): если старая + /// партиция уже успела записаться архивом, а переключение указателя на новую ещё + /// не прошло (SharePoint упал между этими двумя шагами), без фильтра она была бы + /// видна СРАЗУ в обоих качествах — и как архив, и как текущая (см. + /// BuildCurrentAsync). Фильтр держит текущую ровно в одном месте, даже в этом + /// переходном состоянии. /// - public async Task EnsureCurrentRegisteredAsync( + public async Task> 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); + + /// + /// Синтезирует запись о ТЕКУЩЕЙ партиции — без обращения к request_list_partitions + /// на запись: строка появится там только когда эта партиция реально станет + /// архивом (см. ArchiveCurrentAsync). Номер — следующий за последним архивным + /// (или 1, если архива ещё нет); Title читается REST'ом каждый раз, а не + /// кешируется — кешировать было бы негде без строки в БД. + /// + /// archived — уже загруженный список архивных партиций этого типа (см. + /// GetArchivedAsync), передаётся, а не читается заново, чтобы вызывающая + /// сторона (которой archived обычно уже нужен для остальной серии) не делала + /// один и тот же запрос дважды. + /// + public async Task BuildCurrentAsync( + RequestType requestType, IReadOnlyList 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); + } + + /// + /// Записывает ТЕКУЩУЮ партицию как архивную строку — вызывать строго в момент, + /// когда она перестаёт быть текущей (единственный вызывающий — + /// CreateNextListPartitionJob, прямо перед тем как переключить + /// RequestType.ListId на следующую партицию). + /// + /// Читает свой собственный, НЕфильтрованный список существующих партиций — + /// намеренно не переиспользует GetArchivedAsync(): та фильтрует по + /// ListId != RequestType.ListId, и ровно между этим вызовом и переключением + /// указателя (см. CreateNextListPartitionJob) только что записанная строка + /// на мгновение совпадает с текущим ListId — фильтр GetArchivedAsync её бы не + /// увидел, идемпотентность сломалась бы, и повтор Hangfire упал бы на + /// уникальном индексе (request_type_id, number) вместо того, чтобы найти уже + /// записанное и вернуть его. + /// + public async Task 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 TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken) { try diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs index d284ea8..b0567c0 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs @@ -44,7 +44,11 @@ public sealed class ListPartitionService( return Result.Fail(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(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; } /// diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 7829f26..a3a6edd 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -65,27 +65,26 @@ public sealed class ReminderService( /// списке, и не должна пропадать из напоминаний только из-за того, что для /// новых заявок завели следующий список. /// - /// EnsureCurrentRegisteredAsync — на случай, если этот тип заявок ни разу не - /// читали через ListPartitionService (сам реестр партиций мог ещё не знать - /// про текущий список): без неё серия была бы пустой и напоминания молча не - /// строились бы даже по текущему списку. + /// request_list_partitions хранит только архив (см. ListPartitionRegistry) — + /// текущая партиция синтезируется отдельно и добавляется к архиву здесь же, + /// а не читается из БД. /// private async Task> GetOverdueForTypeAsync( RequestType type, DateTimeOffset now, CancellationToken cancellationToken) { - await partitionRegistry.EnsureCurrentRegisteredAsync(type, cancellationToken); + var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken); + var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken); - var partitions = await db.RequestListPartitions.AsNoTracking() - .Where(x => x.RequestTypeId == type.Id) - .OrderBy(x => x.Number) - .ToListAsync(cancellationToken); + var partitions = archived + .Select(x => (x.ListId, x.Number)) + .Append((current.ListId, current.Number)); var reminders = new List(); - foreach (var partition in partitions) + foreach (var (listId, number) in partitions) { try { - reminders.AddRange(await GetOverdueForPartitionAsync(type, partition, now, cancellationToken)); + reminders.AddRange(await GetOverdueForPartitionAsync(type, listId, number, now, cancellationToken)); } catch (Exception e) { @@ -94,7 +93,7 @@ public sealed class ReminderService( // остальным партициям того же типа. logger.LogError( e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны", - partition.ListId, partition.Number, type.Name); + listId, number, type.Name); } } @@ -102,20 +101,18 @@ public sealed class ReminderService( } private async Task> GetOverdueForPartitionAsync( - RequestType type, RequestListPartition partition, DateTimeOffset now, CancellationToken cancellationToken) + RequestType type, string listId, int number, DateTimeOffset now, CancellationToken cancellationToken) { var siteUrl = type.SiteUrl!; - // Читаем Title ЖИВЬЁМ, а не из partition.Title (закешированного на момент - // создания партиции) — тот же принцип, что был здесь и раньше для - // единственного списка: заголовок могли переименовать вручную, и кеш - // тогда соврёт, а по url список найдётся всегда. - var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, partition.ListId, cancellationToken); + // Читаем Title ЖИВЬЁМ — заголовок могли переименовать вручную, а по url + // список найдётся всегда. + var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken); if (string.IsNullOrWhiteSpace(dataListTitle)) { logger.LogWarning( "Список данных партиции №{Number} типа «{Type}» не найден по url {Url}", - partition.Number, type.Name, partition.ListId); + number, type.Name, listId); return []; } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitionRegistryTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitionRegistryTests.cs new file mode 100644 index 0000000..0fe9619 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitionRegistryTests.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using Tnsu.ApprovalRoute.Application.ListPartitioning; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +/// +/// request_list_partitions хранит ТОЛЬКО архив — партиция, которая ни разу не +/// архивировалась, не должна получать строку просто от того, что её прочитали +/// (см. ListPartitionRegistry). Раньше самозаполняющийся реестр писал строку +/// уже на первое чтение, из-за чего один и тот же список оказывался записан +/// дважды: и в RequestType.ListId, и строкой в этой таблице. +/// +public sealed class ListPartitionRegistryTests(PostgresDatabaseFixture fixture) : IClassFixture +{ + private readonly ApprovalRouteDbContext _db = + TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService(); + + private ListPartitionRegistry BuildRegistry() => + new(_db, Mock.Of(), NullLogger.Instance); + + private async Task CreateTypeAsync(string listId) + { + var type = new RequestType + { + Name = $"Тест {Guid.NewGuid():N}", + PageUrl = $"/test-{Guid.NewGuid():N}", + ListId = listId, + SiteUrl = "https://tnsukz.sharepoint.com/sites/requests", + Section = "General" + }; + _db.RequestTypes.Add(type); + await _db.SaveChangesAsync(); + return type; + } + + [Fact(DisplayName = "Партиция, которую ни разу не архивировали, не пишется в request_list_partitions")] + public async Task BuildCurrentAsync_NeverArchived_DoesNotWriteRow() + { + // Arrange + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + + // Action + var archived = await registry.GetArchivedAsync(type, CancellationToken.None); + var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None); + + // Assert + archived.ShouldBeEmpty(); + current.Number.ShouldBe(1); + current.ListId.ShouldBe(type.ListId); + current.IsCurrent.ShouldBeTrue(); + + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .CountAsync(); + rowsInDb.ShouldBe(0); + } + + [Fact(DisplayName = "ArchiveCurrentAsync пишет ровно одну строку архива под номером 1")] + public async Task ArchiveCurrentAsync_WritesExactlyOneRow() + { + // Arrange + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + + // Action + var archived = await registry.ArchiveCurrentAsync(type, CancellationToken.None); + + // Assert + archived.Number.ShouldBe(1); + archived.ListId.ShouldBe("/sites/requests/Lists/1"); + + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .ToListAsync(); + rowsInDb.Count.ShouldBe(1); + rowsInDb[0].ListId.ShouldBe("/sites/requests/Lists/1"); + } + + [Fact(DisplayName = "ArchiveCurrentAsync идемпотентна — повтор не создаёт вторую строку")] + public async Task ArchiveCurrentAsync_CalledTwice_DoesNotDuplicate() + { + // Arrange + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + + // Action — как повторная попытка Hangfire после частичного сбоя: пока указатель + // ещё не переключён, GetArchivedAsync только что записанную строку не увидит + // (фильтрует по ListId != RequestType.ListId) — именно поэтому идемпотентность + // ArchiveCurrentAsync не должна зависеть от него (см. её doc-комментарий). + await registry.ArchiveCurrentAsync(type, CancellationToken.None); + await registry.ArchiveCurrentAsync(type, CancellationToken.None); + + // Assert + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .CountAsync(); + rowsInDb.ShouldBe(1); + } + + [Fact(DisplayName = "После архивации и переключения текущая партиция получает следующий номер")] + public async Task BuildCurrentAsync_AfterArchiving_ReturnsNextNumber() + { + // Arrange: тип с одной партицией, которую сейчас архивируем (как + // CreateNextListPartitionJob перед переключением на следующую). + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + await registry.ArchiveCurrentAsync(type, CancellationToken.None); + + // Action: переключаем указатель на новую партицию (сама она архивной + // строки ещё не получает — см. doc-комментарий ListPartitionRegistry). + type.ListId = "/sites/requests/Lists/2"; + await _db.SaveChangesAsync(); + + var archived = await registry.GetArchivedAsync(type, CancellationToken.None); + var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None); + + // Assert + archived.Count.ShouldBe(1); + archived[0].ListId.ShouldBe("/sites/requests/Lists/1"); + current.Number.ShouldBe(2); + current.ListId.ShouldBe("/sites/requests/Lists/2"); + + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .CountAsync(); + rowsInDb.ShouldBe(1); // новая партиция строки ещё не получила + } +}