diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 7456376..aae5af9 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -187,12 +187,18 @@ public sealed class TemplateRouteService( .OrderBy(s => s.OrderNo) .ToList(); - var steps = new List(); - foreach (var step in matchingSteps) - steps.Add(await ResolveStepAsync( - step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, - HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null, - cancellationToken)); + // Шаги резолвятся ПАРАЛЛЕЛЬНО, а не по очереди: каждый шаг — это отдельные + // походы в api.tnsu.kz (см. ResolveStepAsync), и на длинном маршруте + // (десяток+ шагов) последовательные await один за другим складываются в + // заметную задержку. Task.WhenAll сохраняет порядок результатов (массив + // соответствует порядку задач, а не порядку завершения), поэтому + // OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ + // дубликат считается первым) продолжают работать так же, как при + // последовательном foreach. + var steps = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync( + step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, + HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null, + cancellationToken))); var collapsedSteps = CollapseByExecutorEmail(steps); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index ca7ef6f..9b1ba29 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -29,11 +29,17 @@ public static class InfrastructureServiceCollectionExtensions services.Configure(configuration.GetSection("TnsuPortal")); services.AddHttpClient(); - services.AddHttpClient((sp, client) => + // ITnsuPortalClient резолвится в CachingTnsuPortalClient (кеш на 5 минут, + // см. её комментарий) — сам HttpTnsuPortalClient регистрируем по + // конкретному типу, а не по интерфейсу, чтобы декоратор мог его получить + // как обычную зависимость. + services.AddHttpClient((sp, client) => { var options = sp.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl); }); + services.AddMemoryCache(); + services.AddScoped(); return services; } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs new file mode 100644 index 0000000..6ddab54 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// Кеширующая обёртка над . Резолв одного +/// маршрута (TemplateRouteService.ResolveAsync) может дёргать справочник +/// api.tnsu.kz по многу раз за один запрос — один и тот же согласующий, +/// должность или руководитель нередко повторяется в разных шагах длинного +/// маршрута, — и без кеша распараллеливание самих шагов резолва не спасает от +/// нагрузки на api.tnsu.kz: он просто получает то же число запросов +/// одновременно вместо по очереди. Кеш процессный (, +/// не распределённый — реплик API несколько, поэтому в них он не общий), TTL +/// фиксирован на . +/// +public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryCache cache) : ITnsuPortalClient +{ + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + + public Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) => + GetOrAddAsync( + $"emp:email:{email.ToLowerInvariant()}", + () => inner.GetEmployeeByEmailAsync(email, cancellationToken)); + + public Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) => + GetOrAddAsync( + $"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}", + () => inner.GetEmployeeByPositionAsync(position, company, cancellationToken)); + + public Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) => + GetOrAddAsync( + $"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}", + () => inner.GetManagerAsync(email, managerType, cancellationToken)); + + public Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) => + GetOrAddAsync( + $"projexec:{projectId}", + () => inner.GetProjectExecutorsAsync(projectId, cancellationToken)); + + public Task GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken) => + GetOrAddAsync( + $"depthead:{departmentId}", + () => inner.GetDepartmentHeadEmailAsync(departmentId, cancellationToken)); + + /// + /// Кеширует и «не нашли» (null / пустой список) — повторять запрос к + /// api.tnsu.kz за тем же результатом на каждый одинаковый шаг маршрута + /// незачем. Ключ по одному и тому же значению может задеть гонку: два + /// конкурентных шага (см. Task.WhenAll в ResolveAsync) с одинаковым ключом + /// оба не найдут его в кеше и оба сходят в API — не страшно, это лишний + /// один поход, а не порча данных; блокировка per-key усложнила бы код ради + /// выигрыша, который при TTL в минуты и так почти всегда есть без неё. + /// + private async Task GetOrAddAsync(string key, Func> factory) + { + if (cache.TryGetValue(key, out T? cached)) + return cached!; + + var value = await factory(); + cache.Set(key, value, CacheDuration); + return value; + } +}