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); // новая партиция строки ещё не получила
+ }
+}