2 Commits

Author SHA1 Message Date
gorbenko.pavel feb95f5621 Читать партиции из БД последовательно в by-approver
Tests / frontend-test (pull_request) Successful in 47s
Tests / backend-test (pull_request) Successful in 1m33s
DbContext не потокобезопасен: параллельные задачи по типам заявок падали
с «вторая операция на контексте», а пустой catch превращал это в пустой список.
Теперь БД читается последовательно, параллельно идёт только SharePoint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 15:24:57 +05:00
gorbenko.pavel 3c3c7caa27 Ограничить параллелизм и логировать пропуски в by-approver
Экран «Заявки по участнику» показывал пустой список: чтение 18 типов заявок
шло разом, ошибки SharePoint глотались молча. Теперь не более 3 типов
одновременно, а пропущенные типы/списки пишутся в лог.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 14:53:54 +05:00
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.RegularExpressions;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities;
@@ -25,10 +26,16 @@ namespace Tnsu.ApprovalRoute.Application.RequestManage;
public sealed class RequestManageService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry)
ListPartitionRegistry partitionRegistry,
ILogger<RequestManageService> logger)
{
private const int DefaultPageSize = 20;
// Сколько типов заявок читаем из SharePoint одновременно. Без ограничения
// 18 типов × партиции разом упирались в троттлинг (429), а ошибки глотались
// и экран показывал пустой список.
private const int MaxParallelTypes = 3;
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
// requestManageService.ts (тот же самый список SharePoint, тот же провижининг).
private const string ApproverField = "TansuSiteApprover";
@@ -145,32 +152,51 @@ public sealed class RequestManageService(
var statusFilter = string.Join(" or ", activeStatuses.Select(s => $"{StatusField} eq '{Escape(s)}'"));
var email = approverEmail.Trim();
var results = await Task.WhenAll(
requestTypes.Select(rt => FindForTypeSafeAsync(rt, statusFilter, email, cancellationToken)));
// DbContext не потокобезопасен: архивные партиции читаем из БД ПОСЛЕДОВАТЕЛЬНО,
// и только дальнейшее чтение SharePoint (без БД) идёт параллельно. Раньше
// GetArchivedAsync вызывался из параллельных задач — «вторая операция на
// контексте» падала, а пустой catch превращал это в пустой список.
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
foreach (var rt in requestTypes)
archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken);
using var gate = new SemaphoreSlim(MaxParallelTypes);
var results = await Task.WhenAll(requestTypes.Select(async rt =>
{
await gate.WaitAsync(cancellationToken);
try
{
return await FindForTypeSafeAsync(rt, archivedByType[rt.Id], statusFilter, email, cancellationToken);
}
finally
{
gate.Release();
}
}));
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(results.SelectMany(r => r).ToList());
}
private async Task<IReadOnlyList<UserApprovalRowDto>> FindForTypeSafeAsync(
RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken)
RequestType requestType, IReadOnlyList<RequestListPartition> archived, string statusFilter, string approverEmail, CancellationToken cancellationToken)
{
try
{
return await FindForTypeAsync(requestType, statusFilter, approverEmail, cancellationToken);
return await FindForTypeAsync(requestType, archived, statusFilter, approverEmail, cancellationToken);
}
catch
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Один недоступный/переименованный список не должен обнулять результат по
// остальным типам заявок — та же терпимость, что и в ReminderService.
logger.LogWarning(ex, "by-approver: тип заявок {RequestTypeId} «{RequestTypeName}» пропущен", requestType.Id, requestType.Name);
return [];
}
}
private async Task<IReadOnlyList<UserApprovalRowDto>> FindForTypeAsync(
RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken)
RequestType requestType, IReadOnlyList<RequestListPartition> archived, string statusFilter, string approverEmail, CancellationToken cancellationToken)
{
var siteUrl = requestType.SiteUrl!;
var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken);
var partitions = archived
@@ -190,9 +216,11 @@ public sealed class RequestManageService(
hits = await sharePoint.GetItemsByListTitleAsync(
siteUrl, approvalListTitle, $"{ApprovalRowSelect}&$filter=({statusFilter})&$top=2000", cancellationToken);
}
catch
catch (Exception ex) when (ex is not OperationCanceledException)
{
continue; // партиция сломана/переименована вручную — остальные не теряем.
// партиция сломана/переименована вручную — остальные не теряем.
logger.LogWarning(ex, "by-approver: список «{ApprovalList}» пропущен", approvalListTitle);
continue;
}
var matched = hits