Архивация списков #13
@@ -31,6 +31,9 @@
|
|||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||||
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Hangfire.Dashboard;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Простая Basic Auth для дашборда Hangfire — единственный логин/пароль из
|
||||||
|
/// конфигурации (Hangfire:Dashboard), а не полноценная схема аутентификации: сам
|
||||||
|
/// дашборд не участвует в остальной авторизации API (это отдельная страница,
|
||||||
|
/// открываемая прямо в браузере, не по JWT).
|
||||||
|
///
|
||||||
|
/// Пустые логин/пароль (не заданы в конфигурации) означают «дашборд закрыт для
|
||||||
|
/// всех» — не «открыт для всех»: пустая строка в конфиге не должна случайно снять
|
||||||
|
/// защиту.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HangfireDashboardBasicAuthFilter(string username, string password) : IDashboardAuthorizationFilter
|
||||||
|
{
|
||||||
|
public bool Authorize(DashboardContext context)
|
||||||
|
{
|
||||||
|
var httpContext = context.GetHttpContext();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password) &&
|
||||||
|
TryGetBasicAuthCredentials(httpContext.Request.Headers.Authorization, out var user, out var pass) &&
|
||||||
|
FixedTimeEquals(user, username) && FixedTimeEquals(pass, password))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = "Basic realm=\"Hangfire\"";
|
||||||
|
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetBasicAuthCredentials(
|
||||||
|
string? authorizationHeader, out string username, out string password)
|
||||||
|
{
|
||||||
|
username = string.Empty;
|
||||||
|
password = string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(authorizationHeader) ||
|
||||||
|
!authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var decoded = Encoding.UTF8.GetString(
|
||||||
|
Convert.FromBase64String(authorizationHeader["Basic ".Length..].Trim()));
|
||||||
|
var separatorIndex = decoded.IndexOf(':');
|
||||||
|
if (separatorIndex < 0) return false;
|
||||||
|
|
||||||
|
username = decoded[..separatorIndex];
|
||||||
|
password = decoded[(separatorIndex + 1)..];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
// Битый base64 в заголовке — не наша забота, просто не авторизуем.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сравнение за постоянное время: длина логина/пароля в Basic Auth и так видна
|
||||||
|
// по заголовку, но посимвольное сравнение через == утекало бы ЕЩЁ и через
|
||||||
|
// тайминг совпадения символов — дешёво закрыть и это.
|
||||||
|
private static bool FixedTimeEquals(string a, string b) =>
|
||||||
|
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
|
||||||
|
}
|
||||||
@@ -1,35 +1,45 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Tnsu.ApprovalRoute.Api.Common;
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
using Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Нумерованная серия SharePoint-списков по типу заявок: куда писать сейчас и вся
|
/// Нумерованная серия партиций SharePoint-списка по типу заявок: куда писать сейчас
|
||||||
/// серия для админки.
|
/// и вся серия для админки/портала.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/request-types/{requestTypeId:int}/lists")]
|
[Route("api/request-types")]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
public sealed class RequestTypeListsController(RequestTypeListService service) : ControllerBase
|
public sealed class RequestTypeListsController(ListPartitionService service) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>Вся серия списков типа заявок (архив + текущий), от первого к последнему.</summary>
|
/// <summary>
|
||||||
[HttpGet]
|
/// Серии партиций ВСЕХ типов заявок разом — для портала: чтобы искать по
|
||||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListDto>>), StatusCodes.Status200OK)]
|
/// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно
|
||||||
|
/// запрашивать серию по каждому типу отдельно.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("lists")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListSeriesDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> ListAll(CancellationToken ct) =>
|
||||||
|
(await service.ListAllAsync(ct)).ToActionResult();
|
||||||
|
|
||||||
|
/// <summary>Вся серия партиций одного типа заявок (архив + текущая), от первой к последней.</summary>
|
||||||
|
[HttpGet("{requestTypeId:int}/lists")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestListPartitionDto>>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> List(int requestTypeId, CancellationToken ct) =>
|
public async Task<IActionResult> List(int requestTypeId, CancellationToken ct) =>
|
||||||
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Список, в который нужно писать новые заявки этого типа.
|
/// Партиция, в которую нужно писать новые заявки этого типа.
|
||||||
///
|
///
|
||||||
/// Не чистый GET: если список добрал порог элементов, запрос ставит в очередь
|
/// Не чистый GET: если партиция добрала порог элементов, запрос ставит в очередь
|
||||||
/// фоновую задачу создания следующего (см. RequestTypeListService.GetCurrentAsync).
|
/// фоновую задачу создания следующей (см. ListPartitionService.GetCurrentAsync).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("current")]
|
[HttpGet("{requestTypeId:int}/lists/current")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<CurrentRequestTypeListDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<CurrentListPartitionDto>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> Current(int requestTypeId, CancellationToken ct) =>
|
public async Task<IActionResult> Current(int requestTypeId, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
using Serilog;
|
||||||
using Tnsu.ApprovalRoute.Api.Auth;
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
using Tnsu.ApprovalRoute.Api.Filters;
|
using Tnsu.ApprovalRoute.Api.Filters;
|
||||||
using Tnsu.ApprovalRoute.Application;
|
using Tnsu.ApprovalRoute.Application;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
using Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure;
|
using Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
|
||||||
@@ -20,11 +21,27 @@ const string CorsPolicyName = "Frontend";
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Заменяет собой встроенных провайдеров логирования: все ILogger<T> в приложении
|
||||||
|
// (включая наши логи в ListPartitioning) идут через Serilog в оба синка ниже.
|
||||||
|
// ReadFrom.Configuration — чтобы Logging:LogLevel в appsettings продолжал работать
|
||||||
|
// как обычно, без второй, серилоговской системы настройки уровней рядом с ним.
|
||||||
|
builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||||
|
.ReadFrom.Configuration(context.Configuration)
|
||||||
|
.Enrich.FromLogContext()
|
||||||
|
.WriteTo.Console()
|
||||||
|
// Один файл на календарный месяц (rollOnFileSizeLimit не включаем — ротация
|
||||||
|
// только по времени). retainedFileCountLimit: null — логи не подчищаются
|
||||||
|
// автоматически, ротацией места на диске занимается инфраструктура хоста.
|
||||||
|
.WriteTo.File(
|
||||||
|
"logs/log-.txt",
|
||||||
|
rollingInterval: RollingInterval.Month,
|
||||||
|
retainedFileCountLimit: null));
|
||||||
|
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
{
|
{
|
||||||
options.ForwardedHeaders =
|
options.ForwardedHeaders =
|
||||||
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
|
||||||
options.KnownNetworks.Clear();
|
options.KnownNetworks.Clear();
|
||||||
options.KnownProxies.Clear();
|
options.KnownProxies.Clear();
|
||||||
});
|
});
|
||||||
@@ -40,7 +57,7 @@ builder.Services.AddCors(options =>
|
|||||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddApprovalRouteApplication();
|
builder.Services.AddApprovalRouteApplication();
|
||||||
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
||||||
builder.Services.Configure<ListProvisioningOptions>(builder.Configuration.GetSection("ListProvisioning"));
|
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
|
||||||
|
|
||||||
// Hangfire на том же Postgres, что и остальные данные. Свои таблицы он создаёт сам
|
// Hangfire на том же Postgres, что и остальные данные. Свои таблицы он создаёт сам
|
||||||
// (схема hangfire) при старте — отдельной EF-миграции для них не нужно.
|
// (схема hangfire) при старте — отдельной EF-миграции для них не нужно.
|
||||||
@@ -109,6 +126,19 @@ app.UseCors(CorsPolicyName);
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// Свой Basic Auth, а не схемы из AuthSetup: дашборд Hangfire открывается прямо в
|
||||||
|
// браузере без похода за токеном Entra, ему нужна отдельная, простая проверка.
|
||||||
|
// Логин/пароль — в конфигурации (Hangfire:Dashboard), см. appsettings.json.
|
||||||
|
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||||
|
{
|
||||||
|
Authorization =
|
||||||
|
[
|
||||||
|
new HangfireDashboardBasicAuthFilter(
|
||||||
|
app.Configuration["Hangfire:Dashboard:Username"] ?? string.Empty,
|
||||||
|
app.Configuration["Hangfire:Dashboard:Password"] ?? string.Empty)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
await app.InitAndRunAsync();
|
await app.InitAndRunAsync();
|
||||||
|
|||||||
@@ -14,5 +14,8 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Scalar.AspNetCore" />
|
<PackageReference Include="Scalar.AspNetCore" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Override": {
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
@@ -19,11 +21,17 @@
|
|||||||
"https://localhost:4321"
|
"https://localhost:4321"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ListProvisioning": {
|
"ListPartitioning": {
|
||||||
"ItemCountThreshold": 4500,
|
"ItemCountThreshold": 4500,
|
||||||
"StaleLockAfter": "00:30:00",
|
"StaleLockAfter": "00:30:00",
|
||||||
"CheckThresholdOnRead": true
|
"CheckThresholdOnRead": true
|
||||||
},
|
},
|
||||||
|
"Hangfire": {
|
||||||
|
"Dashboard": {
|
||||||
|
"Username": "admin",
|
||||||
|
"Password": "RzaT2krczbRpE36QAucdCXR"
|
||||||
|
}
|
||||||
|
},
|
||||||
"SharePoint": {
|
"SharePoint": {
|
||||||
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using System.Reflection;
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
using Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
@@ -15,10 +15,10 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
services.AddScoped<ReminderService>();
|
services.AddScoped<ReminderService>();
|
||||||
services.AddScoped<RequestTypeService>();
|
services.AddScoped<RequestTypeService>();
|
||||||
services.AddScoped<RequestTypeListRegistry>();
|
services.AddScoped<ListPartitionRegistry>();
|
||||||
services.AddScoped<RequestTypeListService>();
|
services.AddScoped<ListPartitionService>();
|
||||||
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||||
services.AddScoped<CreateNextRequestTypeListJob>();
|
services.AddScoped<CreateNextListPartitionJob>();
|
||||||
services.AddScoped<TemplateRouteService>();
|
services.AddScoped<TemplateRouteService>();
|
||||||
services.AddScoped<TemplateRouteStepService>();
|
services.AddScoped<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
|||||||
+31
-17
@@ -6,26 +6,37 @@ using Tnsu.ApprovalRoute.Domain.Entities;
|
|||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Фоновая задача: создать следующий список серии по типу заявок, записать его в
|
/// Фоновая задача: создать следующую партицию списка по типу заявок, записать её в
|
||||||
/// реестр и переключить на него запись.
|
/// реестр и переключить на неё запись.
|
||||||
|
///
|
||||||
|
/// Блокировка захватывается ЗДЕСЬ, при каждом запуске (в т.ч. на каждой повторной
|
||||||
|
/// попытке Hangfire), а не в момент постановки в очередь. Enqueue и фактическое
|
||||||
|
/// выполнение — разные события во времени: задача может полежать в очереди, быть
|
||||||
|
/// повторена автоматически после сбоя, в теории — перезапущена вручную из дашборда.
|
||||||
|
/// Всё это заново становится тем же самым «выполнением» с точки зрения Hangfire, но
|
||||||
|
/// не с точки зрения блокировки: settled только выполнение может её удержать, и
|
||||||
|
/// проверять это надо на каждом фактическом входе в код, создающий список в
|
||||||
|
/// SharePoint, а не один раз на входе в очередь. Иначе, к примеру, зависший на
|
||||||
|
/// повторе процесс и новый независимый триггер (например, второй HTTP-запрос,
|
||||||
|
/// поймавший тот же порог до переключения списка) синхронизировались бы неверно.
|
||||||
///
|
///
|
||||||
/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список
|
/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список
|
||||||
/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя
|
/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя
|
||||||
/// безопасен и не создаёт второй список. Больше трёх попыток смысла нет — если
|
/// безопасен и не создаёт вторую партицию. Больше трёх попыток смысла нет — если
|
||||||
/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire,
|
/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire,
|
||||||
/// а не молотить впустую.
|
/// а не молотить впустую.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AutomaticRetry(Attempts = 3)]
|
[AutomaticRetry(Attempts = 3)]
|
||||||
public sealed class CreateNextRequestTypeListJob(
|
public sealed class CreateNextListPartitionJob(
|
||||||
ApprovalRouteDbContext db,
|
ApprovalRouteDbContext db,
|
||||||
RequestTypeProvisioningLock provisioningLock,
|
ListPartitioningLock provisioningLock,
|
||||||
RequestTypeListRegistry registry,
|
ListPartitionRegistry registry,
|
||||||
ISharePointListProvisioner provisioner,
|
ISharePointListProvisioner provisioner,
|
||||||
IOptions<ListProvisioningOptions> options,
|
IOptions<ListPartitioningOptions> options,
|
||||||
ILogger<CreateNextRequestTypeListJob> logger)
|
ILogger<CreateNextListPartitionJob> logger)
|
||||||
{
|
{
|
||||||
public async Task RunAsync(int requestTypeId, CancellationToken cancellationToken)
|
public async Task RunAsync(int requestTypeId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -60,33 +71,36 @@ public sealed class CreateNextRequestTypeListJob(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Задачу могли поставить раньше, чем реестр успел узнать про текущий
|
// Задачу могли поставить раньше, чем реестр успел узнать про текущую
|
||||||
// список (например, ручным запуском) — регистрируем его, иначе серия
|
// партицию (например, ручным запуском) — регистрируем её, иначе серия
|
||||||
// осталась бы без своего первого номера.
|
// осталась бы без своего первого номера.
|
||||||
var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
|
var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
|
||||||
|
|
||||||
var maxNumber = await db.RequestTypeLists
|
var maxNumber = await db.RequestListPartitions
|
||||||
.Where(x => x.RequestTypeId == requestTypeId)
|
.Where(x => x.RequestTypeId == requestTypeId)
|
||||||
.MaxAsync(x => (int?)x.Number, cancellationToken) ?? current.Number;
|
.MaxAsync(x => (int?)x.Number, cancellationToken) ?? current.Number;
|
||||||
|
|
||||||
var nextNumber = maxNumber + 1;
|
var nextNumber = maxNumber + 1;
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Создаю список №{Number} для типа заявок {RequestTypeId} по структуре {SourceListId}.",
|
"Создаю партицию №{Number} для типа заявок {RequestTypeId} по структуре {SourceListId}.",
|
||||||
nextNumber, requestTypeId, requestType.ListId);
|
nextNumber, requestTypeId, requestType.ListId);
|
||||||
|
|
||||||
|
// Партиция всегда создаётся на том же сайте, что и текущая (см. комментарий
|
||||||
|
// на RequestListPartition о недопустимости разъезда серии по сайтам) — сайт
|
||||||
|
// берём из RequestType, а не откуда-либо ещё, так что расхождение
|
||||||
|
// структурно невозможно.
|
||||||
var created = await provisioner.CreateNextListPairAsync(
|
var created = await provisioner.CreateNextListPairAsync(
|
||||||
requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken);
|
requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken);
|
||||||
|
|
||||||
// Реестр и переключение записи — одной транзакцией: иначе можно остаться
|
// Реестр и переключение записи — одной транзакцией: иначе можно остаться
|
||||||
// с записанным в реестр списком, в который никто не пишет, или наоборот.
|
// с записанной в реестр партицией, в которую никто не пишет, или наоборот.
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
db.RequestTypeLists.Add(new RequestTypeList
|
db.RequestListPartitions.Add(new RequestListPartition
|
||||||
{
|
{
|
||||||
RequestTypeId = requestTypeId,
|
RequestTypeId = requestTypeId,
|
||||||
Number = nextNumber,
|
Number = nextNumber,
|
||||||
SiteUrl = requestType.SiteUrl,
|
|
||||||
ListId = created.ListId,
|
ListId = created.ListId,
|
||||||
Title = created.Title,
|
Title = created.Title,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
@@ -98,7 +112,7 @@ public sealed class CreateNextRequestTypeListJob(
|
|||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Список №{Number} ({ListId}) создан, запись по типу заявок {RequestTypeId} переключена на него.",
|
"Партиция №{Number} ({ListId}) создана, запись по типу заявок {RequestTypeId} переключена на неё.",
|
||||||
nextNumber, created.ListId, requestTypeId);
|
nextNumber, created.ListId, requestTypeId);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
|
/// <summary>Одна партиция серии — для админки/портала (вся серия) и для ответа «текущий список».</summary>
|
||||||
|
/// <param name="IsCurrent">
|
||||||
|
/// true — в эту партицию идёт запись новых заявок; остальные партиции серии — архив только для чтения.
|
||||||
|
/// </param>
|
||||||
|
public sealed record RequestListPartitionDto(
|
||||||
|
int Number,
|
||||||
|
string SiteUrl,
|
||||||
|
string ListId,
|
||||||
|
string? Title,
|
||||||
|
bool IsCurrent,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>Вся серия партиций одного типа заявок — единица ответа bulk-эндпоинта для портала.</summary>
|
||||||
|
public sealed record RequestTypeListSeriesDto(
|
||||||
|
int RequestTypeId,
|
||||||
|
IReadOnlyList<RequestListPartitionDto> Partitions);
|
||||||
|
|
||||||
|
/// <summary>Ответ «куда писать новую заявку».</summary>
|
||||||
|
/// <param name="ItemCount">
|
||||||
|
/// Сколько элементов сейчас в списке — по нему видно, насколько он близок к порогу.
|
||||||
|
/// null, если SharePoint не ответил (тогда условие создания следующей партиции не проверялось).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ProvisioningQueued">
|
||||||
|
/// true — этим запросом поставлена в очередь задача создания следующей партиции серии.
|
||||||
|
/// </param>
|
||||||
|
public sealed record CurrentListPartitionDto(
|
||||||
|
int RequestTypeId,
|
||||||
|
int Number,
|
||||||
|
string SiteUrl,
|
||||||
|
string ListId,
|
||||||
|
string? Title,
|
||||||
|
int? ItemCount = null,
|
||||||
|
bool ProvisioningQueued = false);
|
||||||
+12
-13
@@ -4,49 +4,48 @@ using Tnsu.ApprovalRoute.Domain.Entities;
|
|||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Наполнение реестра списков. Вынесено отдельно, потому что нужно и читающему
|
/// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему
|
||||||
/// эндпоинту, и фоновой задаче: реестр появился позже самих списков, и любой из них
|
/// эндпоинту, и фоновой задаче: реестр появился позже самих списков, и любой из них
|
||||||
/// может встретить тип заявок, которого в реестре ещё нет.
|
/// может встретить тип заявок, которого в реестре ещё нет.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RequestTypeListRegistry(
|
public sealed class ListPartitionRegistry(
|
||||||
ApprovalRouteDbContext db,
|
ApprovalRouteDbContext db,
|
||||||
ISharePointClient sharePoint,
|
ISharePointClient sharePoint,
|
||||||
ILogger<RequestTypeListRegistry> logger)
|
ILogger<ListPartitionRegistry> logger)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Гарантирует, что текущий список типа заявок есть в реестре, и возвращает его строку.
|
/// Гарантирует, что текущая партиция типа заявок есть в реестре, и возвращает её строку.
|
||||||
///
|
///
|
||||||
/// Реестр самозаполняющийся: до этой фичи списки жили только в
|
/// Реестр самозаполняющийся: до этой фичи списки жили только в
|
||||||
/// RequestType.ListId, и переключить список руками в админке можно и сейчас.
|
/// RequestType.ListId, и переключить список руками в админке можно и сейчас.
|
||||||
/// Поэтому неизвестный реестру ListId дописывается следующим номером, а не
|
/// Поэтому неизвестный реестру ListId дописывается следующим номером, а не
|
||||||
/// считается ошибкой.
|
/// считается ошибкой.
|
||||||
///
|
///
|
||||||
/// Требует заполненных ListId/SiteUrl — вызывающая сторона это проверяет.
|
/// Требует заполненного ListId — вызывающая сторона это проверяет.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<RequestTypeList> EnsureCurrentRegisteredAsync(
|
public async Task<RequestListPartition> EnsureCurrentRegisteredAsync(
|
||||||
RequestType requestType, CancellationToken cancellationToken)
|
RequestType requestType, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var series = await db.RequestTypeLists
|
var series = await db.RequestListPartitions
|
||||||
.Where(x => x.RequestTypeId == requestType.Id)
|
.Where(x => x.RequestTypeId == requestType.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var existing = series.FirstOrDefault(x => x.ListId == requestType.ListId);
|
var existing = series.FirstOrDefault(x => x.ListId == requestType.ListId);
|
||||||
if (existing is not null) return existing;
|
if (existing is not null) return existing;
|
||||||
|
|
||||||
var entity = new RequestTypeList
|
var entity = new RequestListPartition
|
||||||
{
|
{
|
||||||
RequestTypeId = requestType.Id,
|
RequestTypeId = requestType.Id,
|
||||||
Number = series.Count == 0 ? 1 : series.Max(x => x.Number) + 1,
|
Number = series.Count == 0 ? 1 : series.Max(x => x.Number) + 1,
|
||||||
SiteUrl = requestType.SiteUrl!,
|
|
||||||
ListId = requestType.ListId!,
|
ListId = requestType.ListId!,
|
||||||
Title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken),
|
Title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken),
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
db.RequestTypeLists.Add(entity);
|
db.RequestListPartitions.Add(entity);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
@@ -56,8 +55,8 @@ public sealed class RequestTypeListRegistry(
|
|||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Заголовок нужен только для отображения в админке — из-за его отсутствия
|
// Заголовок нужен только для отображения в админке/портале — из-за его
|
||||||
// регистрация списка падать не должна.
|
// отсутствия регистрация партиции падать не должна.
|
||||||
private async Task<string?> TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken)
|
private async Task<string?> TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
+64
-36
@@ -8,40 +8,40 @@ using Tnsu.ApprovalRoute.Domain.Entities;
|
|||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Реестр нумерованных списков по типу заявок: какой список текущий (куда писать) и
|
/// Реестр нумерованных партиций списка по типу заявок: какая партиция текущая (куда
|
||||||
/// вся серия целиком (архив + текущий) для админки.
|
/// писать) и вся серия целиком (архив + текущая) для админки и портала.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RequestTypeListService(
|
public sealed class ListPartitionService(
|
||||||
ApprovalRouteDbContext db,
|
ApprovalRouteDbContext db,
|
||||||
ISharePointClient sharePoint,
|
ISharePointClient sharePoint,
|
||||||
RequestTypeListRegistry registry,
|
ListPartitionRegistry registry,
|
||||||
IBackgroundJobClient backgroundJobs,
|
IBackgroundJobClient backgroundJobs,
|
||||||
IOptions<ListProvisioningOptions> options,
|
IOptions<ListPartitioningOptions> options,
|
||||||
ILogger<RequestTypeListService> logger)
|
ILogger<ListPartitionService> logger)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Список, в который сейчас нужно писать новые заявки.
|
/// Партиция, в которую сейчас нужно писать новые заявки.
|
||||||
///
|
///
|
||||||
/// Побочный эффект: если в списке набрался порог элементов, ставит в очередь
|
/// Побочный эффект: если партиция набрала порог элементов, ставит в очередь
|
||||||
/// задачу создания следующего. Побочный эффект в GET — сознательный компромисс
|
/// задачу создания следующей. Побочный эффект в GET — сознательный компромисс
|
||||||
/// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой
|
/// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой
|
||||||
/// записью, и это единственный момент, когда мы точно знаем, что список живой.
|
/// записью, и это единственный момент, когда мы точно знаем, что список живой.
|
||||||
/// Повторная постановка безопасна — задача разруливает гонку захватом блокировки.
|
/// Повторная постановка безопасна — задача разруливает гонку захватом блокировки.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<Result<CurrentRequestTypeListDto>> GetCurrentAsync(
|
public async Task<Result<CurrentListPartitionDto>> GetCurrentAsync(
|
||||||
int requestTypeId, CancellationToken cancellationToken)
|
int requestTypeId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var requestType = await db.RequestTypes
|
var requestType = await db.RequestTypes
|
||||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||||
|
|
||||||
if (requestType is null)
|
if (requestType is null)
|
||||||
return Result.Fail<CurrentRequestTypeListDto>(new NotFoundError("Тип заявки", requestTypeId));
|
return Result.Fail<CurrentListPartitionDto>(new NotFoundError("Тип заявки", requestTypeId));
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||||
return Result.Fail<CurrentRequestTypeListDto>(new ValidationError(
|
return Result.Fail<CurrentListPartitionDto>(new ValidationError(
|
||||||
$"У типа заявки {requestTypeId} не настроен список SharePoint."));
|
$"У типа заявки {requestTypeId} не настроен список SharePoint."));
|
||||||
|
|
||||||
var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
|
var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
|
||||||
@@ -49,33 +49,61 @@ public sealed class RequestTypeListService(
|
|||||||
var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken);
|
var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken);
|
||||||
var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken);
|
var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken);
|
||||||
|
|
||||||
return Result.Ok(new CurrentRequestTypeListDto(
|
return Result.Ok(new CurrentListPartitionDto(
|
||||||
requestTypeId, current.Number, current.SiteUrl, current.ListId, current.Title, itemCount, queued));
|
requestTypeId, current.Number, requestType.SiteUrl, current.ListId, current.Title, itemCount, queued));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Вся серия списков типа заявок, от первого к последнему — для админки.</summary>
|
/// <summary>Вся серия партиций типа заявок, от первой к последней — для админки.</summary>
|
||||||
public async Task<Result<IReadOnlyList<RequestTypeListDto>>> ListAsync(
|
public async Task<Result<IReadOnlyList<RequestListPartitionDto>>> ListAsync(
|
||||||
int requestTypeId, CancellationToken cancellationToken)
|
int requestTypeId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var requestType = await db.RequestTypes
|
var requestType = await db.RequestTypes
|
||||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||||
|
|
||||||
if (requestType is null)
|
if (requestType is null)
|
||||||
return Result.Fail<IReadOnlyList<RequestTypeListDto>>(new NotFoundError("Тип заявки", requestTypeId));
|
return Result.Fail<IReadOnlyList<RequestListPartitionDto>>(new NotFoundError("Тип заявки", requestTypeId));
|
||||||
|
|
||||||
// Тип без настроенного списка — пустая серия, а не ошибка: в админке такой
|
return Result.Ok(await BuildSeriesAsync(requestType, cancellationToken));
|
||||||
// тип заявок вполне может существовать до привязки к SharePoint.
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(requestType.ListId) && !string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
|
||||||
await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
|
|
||||||
|
|
||||||
var items = await db.RequestTypeLists.AsNoTracking()
|
/// <summary>
|
||||||
.Where(x => x.RequestTypeId == requestTypeId)
|
/// Серии всех типов заявок разом — для портала: чтобы искать по заявкам сразу во
|
||||||
.OrderBy(x => x.Number)
|
/// всех партициях (архив + текущая) любого типа, порталу нужен весь список
|
||||||
.Select(x => new RequestTypeListDto(
|
/// адресов один раз, а не по одному запросу на тип. Типы без настроенного
|
||||||
x.Number, x.SiteUrl, x.ListId, x.Title, x.ListId == requestType.ListId, x.CreatedAt))
|
/// списка SharePoint в ответ не попадают — искать там всё равно нечего.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Result<IReadOnlyList<RequestTypeListSeriesDto>>> ListAllAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestTypes = await db.RequestTypes
|
||||||
|
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Ok<IReadOnlyList<RequestTypeListDto>>(items);
|
var result = new List<RequestTypeListSeriesDto>(requestTypes.Count);
|
||||||
|
foreach (var requestType in requestTypes)
|
||||||
|
{
|
||||||
|
var partitions = await BuildSeriesAsync(requestType, cancellationToken);
|
||||||
|
result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<RequestTypeListSeriesDto>>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<RequestListPartitionDto>> BuildSeriesAsync(
|
||||||
|
RequestType requestType, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Тип без настроенного списка — пустая серия, а не ошибка: в админке такой
|
||||||
|
// тип заявок вполне может существовать до привязки к SharePoint.
|
||||||
|
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
await registry.EnsureCurrentRegisteredAsync(requestType, 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -99,7 +127,7 @@ public sealed class RequestTypeListService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ставит задачу создания следующего списка, если порог достигнут. Возвращает
|
/// Ставит задачу создания следующей партиции, если порог достигнут. Возвращает
|
||||||
/// true, только если задача действительно поставлена.
|
/// true, только если задача действительно поставлена.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<bool> TryEnqueueProvisioningAsync(
|
private async Task<bool> TryEnqueueProvisioningAsync(
|
||||||
@@ -107,23 +135,23 @@ public sealed class RequestTypeListService(
|
|||||||
{
|
{
|
||||||
if (itemCount is null || itemCount < options.Value.ItemCountThreshold) return false;
|
if (itemCount is null || itemCount < options.Value.ItemCountThreshold) return false;
|
||||||
|
|
||||||
// Следующий список уже создан, а запись на него ещё не переключили — задача
|
// Следующая партиция уже создана, а запись на неё ещё не переключили —
|
||||||
// не нужна (и переключать здесь нельзя: этим владеет провижининг).
|
// задача не нужна (и переключать здесь нельзя: этим владеет провижининг).
|
||||||
var hasNext = await db.RequestTypeLists
|
var hasNext = await db.RequestListPartitions
|
||||||
.AnyAsync(x => x.RequestTypeId == requestType.Id && x.Number > currentNumber, cancellationToken);
|
.AnyAsync(x => x.RequestTypeId == requestType.Id && x.Number > currentNumber, cancellationToken);
|
||||||
if (hasNext) return false;
|
if (hasNext) return false;
|
||||||
|
|
||||||
// Провижининг уже идёт. Проверка неатомарная и это нормально: она экономит
|
// Провижининг уже идёт. Проверка неатомарная и это нормально: она экономит
|
||||||
// постановку лишней задачи, а настоящую гонку ловит захват блокировки внутри
|
// постановку лишней задачи, а настоящую гонку ловит захват блокировки внутри
|
||||||
// самой задачи.
|
// самой задачи (см. CreateNextListPartitionJob).
|
||||||
if (requestType.ListProvisioningStartedAt is not null) return false;
|
if (requestType.ListPartitioningStartedAt is not null) return false;
|
||||||
|
|
||||||
backgroundJobs.Enqueue<CreateNextRequestTypeListJob>(
|
backgroundJobs.Enqueue<CreateNextListPartitionJob>(
|
||||||
job => job.RunAsync(requestType.Id, CancellationToken.None));
|
job => job.RunAsync(requestType.Id, CancellationToken.None));
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " +
|
"В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " +
|
||||||
"поставлена задача создания списка №{NextNumber}.",
|
"поставлена задача создания партиции №{NextNumber}.",
|
||||||
requestType.ListId, requestType.Id, itemCount, options.Value.ItemCountThreshold, currentNumber + 1);
|
requestType.ListId, requestType.Id, itemCount, options.Value.ItemCountThreshold, currentNumber + 1);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
+9
-8
@@ -1,30 +1,31 @@
|
|||||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
/// <summary>Настройки нумерованных списков заявок (секция ListProvisioning в конфигурации).</summary>
|
/// <summary>Настройки нумерованных партиций списков заявок (секция ListPartitioning в конфигурации).</summary>
|
||||||
public sealed class ListProvisioningOptions
|
public sealed class ListPartitioningOptions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Порог элементов в списке данных, по достижении которого ставится задача на
|
/// Порог элементов в списке данных, по достижении которого ставится задача на
|
||||||
/// создание следующей пары списков.
|
/// создание следующей партиции (пары списков).
|
||||||
///
|
///
|
||||||
/// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а
|
/// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а
|
||||||
/// «list view threshold»: запросы, которым приходится просмотреть больше 5000
|
/// «list view threshold»: запросы, которым приходится просмотреть больше 5000
|
||||||
/// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть
|
/// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть
|
||||||
/// ниже, чтобы задача успела создать следующий список до того, как текущий
|
/// ниже, чтобы задача успела создать следующую партицию до того, как текущая
|
||||||
/// начнёт упираться.
|
/// начнёт упираться.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ItemCountThreshold { get; set; } = 4500;
|
public int ItemCountThreshold { get; set; } = 4500;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Через сколько блокировка провижининга считается брошенной и может быть
|
/// Через сколько блокировка провижининга считается брошенной и может быть
|
||||||
/// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListProvisioningStartedAt.
|
/// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListPartitioningStartedAt.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30);
|
public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только
|
/// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только
|
||||||
/// отдаёт список, не обращаясь к SharePoint и никого не ставя в очередь
|
/// отдаёт список, не обращаясь к SharePoint за числом элементов и никого не
|
||||||
/// (например, пока провижинер не готов).
|
/// ставя в очередь. Полезно, если SharePoint временно недоступен или нужно
|
||||||
|
/// приостановить автосоздание партиций, не трогая остальную логику.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool CheckThresholdOnRead { get; set; } = true;
|
public bool CheckThresholdOnRead { get; set; } = true;
|
||||||
}
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
|
||||||
|
|
||||||
/// <summary>Один список серии — для админки (вся серия) и для ответа «текущий список».</summary>
|
|
||||||
/// <param name="IsCurrent">
|
|
||||||
/// true — в этот список идёт запись новых заявок; остальные серии — архив только для чтения.
|
|
||||||
/// </param>
|
|
||||||
public sealed record RequestTypeListDto(
|
|
||||||
int Number,
|
|
||||||
string SiteUrl,
|
|
||||||
string ListId,
|
|
||||||
string? Title,
|
|
||||||
bool IsCurrent,
|
|
||||||
DateTimeOffset CreatedAt);
|
|
||||||
|
|
||||||
/// <summary>Ответ «куда писать новую заявку».</summary>
|
|
||||||
/// <param name="ItemCount">
|
|
||||||
/// Сколько элементов сейчас в списке — по нему видно, насколько он близок к порогу.
|
|
||||||
/// null, если SharePoint не ответил (тогда условие создания следующего не проверялось).
|
|
||||||
/// </param>
|
|
||||||
/// <param name="ProvisioningQueued">
|
|
||||||
/// true — этим запросом поставлена в очередь задача создания следующего списка серии.
|
|
||||||
/// </param>
|
|
||||||
public sealed record CurrentRequestTypeListDto(
|
|
||||||
int RequestTypeId,
|
|
||||||
int Number,
|
|
||||||
string SiteUrl,
|
|
||||||
string ListId,
|
|
||||||
string? Title,
|
|
||||||
int? ItemCount = null,
|
|
||||||
bool ProvisioningQueued = false);
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один SharePoint-список данных из нумерованной серии по типу заявок.
|
||||||
|
///
|
||||||
|
/// Списки заявок нумерованные («<Форма> <N> — данные»): когда в текущем
|
||||||
|
/// списке набирается порог элементов, рядом создаётся следующая пара (партиция), а
|
||||||
|
/// прежняя остаётся архивом только для чтения. Эта таблица — реестр всей серии: без
|
||||||
|
/// неё поиск по заявкам типа не знает, по каким спискам искать.
|
||||||
|
///
|
||||||
|
/// Текущий (тот, куда идёт запись) список продолжает лежать в
|
||||||
|
/// <see cref="RequestType.ListId"/> — реестр его дублирует строкой с наибольшим
|
||||||
|
/// <see cref="Number"/>, но источником правды для записи остаётся RequestType.
|
||||||
|
///
|
||||||
|
/// Сайта у партиции нет намеренно: вся серия живёт на одном сайте — том, что указан
|
||||||
|
/// в <see cref="RequestType.SiteUrl"/> на момент создания партиции. Хранить его
|
||||||
|
/// отдельно на каждой партиции — заводить возможность архивам разъехаться по разным
|
||||||
|
/// сайтам, а такой сценарий не поддерживается ни провижинером (см.
|
||||||
|
/// SharePointListProvisioner — всегда пишет на siteUrl запрашивающего типа заявок),
|
||||||
|
/// ни поиском. Если сайт типа заявок когда-нибудь придётся сменить, старые партиции
|
||||||
|
/// нужно будет переносить осознанно, а не молча продолжать читать их со старого адреса.
|
||||||
|
/// </summary>
|
||||||
|
public class RequestListPartition
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int RequestTypeId { get; set; }
|
||||||
|
public RequestType? RequestType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Номер партиции в серии, с 1. Уникален в пределах типа заявок.</summary>
|
||||||
|
public int Number { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Серверный относительный url списка — та же семантика, что у
|
||||||
|
/// <see cref="RequestType.ListId"/> (несмотря на имя, это url, а не GUID).
|
||||||
|
/// </summary>
|
||||||
|
public string ListId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Title списка на момент создания — для админки, чтобы показать серию без
|
||||||
|
/// похода в SharePoint за каждым заголовком. Может разойтись с фактическим,
|
||||||
|
/// если список переименовали вручную.
|
||||||
|
/// </summary>
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -26,22 +26,22 @@ public class RequestType
|
|||||||
public bool? AskOverdueReason { get; set; }
|
public bool? AskOverdueReason { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Момент, когда фоновая задача создания следующего списка взяла эту строку в
|
/// Момент, когда фоновая задача создания следующей партиции списка взяла эту
|
||||||
/// работу. null — задача не выполняется.
|
/// строку в работу. null — задача не выполняется.
|
||||||
///
|
///
|
||||||
/// Одновременно и признак «идёт провижининг», и БЛОКИРОВКА: задача стартует
|
/// Одновременно и признак «идёт провижининг», и БЛОКИРОВКА: задача стартует
|
||||||
/// только если сумела перевести поле из null в now() одним условным UPDATE
|
/// только если сумела перевести поле из null в now() одним условным UPDATE
|
||||||
/// (см. RequestTypeProvisioningLock) — тогда две параллельные задачи по одному
|
/// (см. ListPartitioningLock) — тогда две параллельные задачи по одному типу
|
||||||
/// типу заявок невозможны.
|
/// заявок невозможны.
|
||||||
///
|
///
|
||||||
/// Метка времени, а не bool, намеренно: упавший процесс не успевает снять
|
/// Метка времени, а не bool, намеренно: упавший процесс не успевает снять
|
||||||
/// флаг, и bool заблокировал бы тип навсегда, а по метке видно, что блокировка
|
/// флаг, и bool заблокировал бы тип навсегда, а по метке видно, что блокировка
|
||||||
/// просрочена, и её можно перехватить.
|
/// просрочена, и её можно перехватить.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTimeOffset? ListProvisioningStartedAt { get; set; }
|
public DateTimeOffset? ListPartitioningStartedAt { get; set; }
|
||||||
|
|
||||||
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
|
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
|
||||||
|
|
||||||
/// <summary>Нумерованная серия списков этого типа заявок (архив + текущий).</summary>
|
/// <summary>Нумерованная серия партиций списка этого типа заявок (архив + текущая).</summary>
|
||||||
public ICollection<RequestTypeList> Lists { get; set; } = new List<RequestTypeList>();
|
public ICollection<RequestListPartition> ListPartitions { get; set; } = new List<RequestListPartition>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Один SharePoint-список данных из нумерованной серии по типу заявок.
|
|
||||||
///
|
|
||||||
/// Списки заявок нумерованные («<Форма> <N> — данные», см. провижинер
|
|
||||||
/// request-portal): когда в текущем списке набирается порог элементов, рядом
|
|
||||||
/// создаётся следующая пара, а прежняя остаётся архивом только для чтения. Эта
|
|
||||||
/// таблица — реестр всей серии: без неё поиск по заявкам типа не знает, по каким
|
|
||||||
/// спискам искать, а провижинер — какой номер выдавать следующим.
|
|
||||||
///
|
|
||||||
/// Текущий (тот, куда идёт запись) список продолжает лежать в
|
|
||||||
/// <see cref="RequestType.ListId"/> — реестр его дублирует строкой с наибольшим
|
|
||||||
/// <see cref="Number"/>, но источником правды для записи остаётся RequestType.
|
|
||||||
/// </summary>
|
|
||||||
public class RequestTypeList
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
|
|
||||||
public int RequestTypeId { get; set; }
|
|
||||||
public RequestType? RequestType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Номер списка в серии, с 1. Уникален в пределах типа заявок.</summary>
|
|
||||||
public int Number { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Сайт SharePoint, где живёт список (серия может переезжать между сайтами).</summary>
|
|
||||||
public string SiteUrl { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Серверный относительный url списка — та же семантика, что у
|
|
||||||
/// <see cref="RequestType.ListId"/> (несмотря на имя, это url, а не GUID).
|
|
||||||
/// </summary>
|
|
||||||
public string ListId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Title списка на момент создания — для админки, чтобы показать серию без
|
|
||||||
/// похода в SharePoint за каждым заголовком. Может разойтись с фактическим,
|
|
||||||
/// если список переименовали вручную.
|
|
||||||
/// </summary>
|
|
||||||
public string? Title { get; set; }
|
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
|
||||||
}
|
|
||||||
+1
-1
@@ -20,7 +20,7 @@ public static class InfrastructureServiceCollectionExtensions
|
|||||||
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
|
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
|
||||||
.UseSnakeCaseNamingConvention());
|
.UseSnakeCaseNamingConvention());
|
||||||
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
||||||
services.AddScoped<RequestTypeProvisioningLock>();
|
services.AddScoped<ListPartitioningLock>();
|
||||||
|
|
||||||
services.Configure<SharePointOptions>(configuration.GetSection("SharePoint"));
|
services.Configure<SharePointOptions>(configuration.GetSection("SharePoint"));
|
||||||
// Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё
|
// Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|||||||
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
||||||
public DbSet<RequestTypeList> RequestTypeLists => Set<RequestTypeList>();
|
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
|
||||||
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
||||||
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
||||||
public DbSet<AppSetting> Settings => Set<AppSetting>();
|
public DbSet<AppSetting> Settings => Set<AppSetting>();
|
||||||
@@ -34,13 +34,12 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
|||||||
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
|
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RequestTypeList>(e =>
|
modelBuilder.Entity<RequestListPartition>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
e.Property(x => x.SiteUrl).HasMaxLength(512).IsRequired();
|
|
||||||
e.Property(x => x.ListId).HasMaxLength(512).IsRequired();
|
e.Property(x => x.ListId).HasMaxLength(512).IsRequired();
|
||||||
e.Property(x => x.Title).HasMaxLength(512);
|
e.Property(x => x.Title).HasMaxLength(512);
|
||||||
e.HasOne(x => x.RequestType).WithMany(x => x.Lists).HasForeignKey(x => x.RequestTypeId);
|
e.HasOne(x => x.RequestType).WithMany(x => x.ListPartitions).HasForeignKey(x => x.RequestTypeId);
|
||||||
// Номер уникален в пределах типа заявок — вторая попытка записать тот же
|
// Номер уникален в пределах типа заявок — вторая попытка записать тот же
|
||||||
// номер (например, при двойном запуске задачи) упадёт на этом индексе,
|
// номер (например, при двойном запуске задачи) упадёт на этом индексе,
|
||||||
// а не создаст в реестре дубль.
|
// а не создаст в реестре дубль.
|
||||||
|
|||||||
+6
-6
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Блокировка «по типу заявок идёт создание следующего списка» на строке
|
/// Блокировка «по типу заявок идёт создание следующей партиции списка» на строке
|
||||||
/// request_types.
|
/// request_types.
|
||||||
///
|
///
|
||||||
/// Почему сырой SQL, а не обычный EF: через change tracker это было бы
|
/// Почему сырой SQL, а не обычный EF: через change tracker это было бы
|
||||||
@@ -17,7 +17,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|||||||
/// блокировку было видно в самой строке (админке — что провижининг идёт) и чтобы
|
/// блокировку было видно в самой строке (админке — что провижининг идёт) и чтобы
|
||||||
/// она переживала возврат соединения в пул между шагами задачи.
|
/// она переживала возврат соединения в пул между шагами задачи.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RequestTypeProvisioningLock(ApprovalRouteDbContext db)
|
public sealed class ListPartitioningLock(ApprovalRouteDbContext db)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Попытаться захватить блокировку. true — захватили (и только тогда можно
|
/// Попытаться захватить блокировку. true — захватили (и только тогда можно
|
||||||
@@ -33,10 +33,10 @@ public sealed class RequestTypeProvisioningLock(ApprovalRouteDbContext db)
|
|||||||
var affected = await db.Database.ExecuteSqlInterpolatedAsync(
|
var affected = await db.Database.ExecuteSqlInterpolatedAsync(
|
||||||
$"""
|
$"""
|
||||||
UPDATE request_types
|
UPDATE request_types
|
||||||
SET list_provisioning_started_at = now()
|
SET list_partitioning_started_at = now()
|
||||||
WHERE id = {requestTypeId}
|
WHERE id = {requestTypeId}
|
||||||
AND (list_provisioning_started_at IS NULL
|
AND (list_partitioning_started_at IS NULL
|
||||||
OR list_provisioning_started_at < now() - CAST({staleAfter} AS interval))
|
OR list_partitioning_started_at < now() - CAST({staleAfter} AS interval))
|
||||||
""",
|
""",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
@@ -46,6 +46,6 @@ public sealed class RequestTypeProvisioningLock(ApprovalRouteDbContext db)
|
|||||||
/// <summary>Снять блокировку. Идемпотентно: снятая блокировка снимается повторно без ошибки.</summary>
|
/// <summary>Снять блокировку. Идемпотентно: снятая блокировка снимается повторно без ошибки.</summary>
|
||||||
public Task ReleaseAsync(int requestTypeId, CancellationToken cancellationToken) =>
|
public Task ReleaseAsync(int requestTypeId, CancellationToken cancellationToken) =>
|
||||||
db.Database.ExecuteSqlInterpolatedAsync(
|
db.Database.ExecuteSqlInterpolatedAsync(
|
||||||
$"UPDATE request_types SET list_provisioning_started_at = NULL WHERE id = {requestTypeId}",
|
$"UPDATE request_types SET list_partitioning_started_at = NULL WHERE id = {requestTypeId}",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
+50
-56
@@ -12,8 +12,8 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|||||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApprovalRouteDbContext))]
|
[DbContext(typeof(ApprovalRouteDbContext))]
|
||||||
[Migration("20260817084927_AddRequestTypeListsRegistry")]
|
[Migration("20260817102423_AddListPartitioning")]
|
||||||
partial class AddRequestTypeListsRegistry
|
partial class AddListPartitioning
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -25,6 +25,48 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<int>("Number")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("number");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("title");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_list_partitions");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "Number")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
|
||||||
|
|
||||||
|
b.ToTable("request_list_partitions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -43,9 +85,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(512)")
|
.HasColumnType("character varying(512)")
|
||||||
.HasColumnName("list_id");
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("ListProvisioningStartedAt")
|
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("list_provisioning_started_at");
|
.HasColumnName("list_partitioning_started_at");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -80,54 +122,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("request_types", (string)null);
|
b.ToTable("request_types", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<string>("ListId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(512)
|
|
||||||
.HasColumnType("character varying(512)")
|
|
||||||
.HasColumnName("list_id");
|
|
||||||
|
|
||||||
b.Property<int>("Number")
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("number");
|
|
||||||
|
|
||||||
b.Property<int>("RequestTypeId")
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("request_type_id");
|
|
||||||
|
|
||||||
b.Property<string>("SiteUrl")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(512)
|
|
||||||
.HasColumnType("character varying(512)")
|
|
||||||
.HasColumnName("site_url");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.HasMaxLength(512)
|
|
||||||
.HasColumnType("character varying(512)")
|
|
||||||
.HasColumnName("title");
|
|
||||||
|
|
||||||
b.HasKey("Id")
|
|
||||||
.HasName("pk_request_type_lists");
|
|
||||||
|
|
||||||
b.HasIndex("RequestTypeId", "Number")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("ix_request_type_lists_request_type_id_number");
|
|
||||||
|
|
||||||
b.ToTable("request_type_lists", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -267,14 +261,14 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("settings", (string)null);
|
b.ToTable("settings", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
.WithMany("Lists")
|
.WithMany("ListPartitions")
|
||||||
.HasForeignKey("RequestTypeId")
|
.HasForeignKey("RequestTypeId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_request_type_lists_request_types_request_type_id");
|
.HasConstraintName("fk_request_list_partitions_request_types_request_type_id");
|
||||||
|
|
||||||
b.Navigation("RequestType");
|
b.Navigation("RequestType");
|
||||||
});
|
});
|
||||||
@@ -305,7 +299,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Lists");
|
b.Navigation("ListPartitions");
|
||||||
|
|
||||||
b.Navigation("Routes");
|
b.Navigation("Routes");
|
||||||
});
|
});
|
||||||
+9
-10
@@ -7,35 +7,34 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|||||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddRequestTypeListsRegistry : Migration
|
public partial class AddListPartitioning : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
name: "list_provisioning_started_at",
|
name: "list_partitioning_started_at",
|
||||||
table: "request_types",
|
table: "request_types",
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "request_type_lists",
|
name: "request_list_partitions",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
id = table.Column<int>(type: "integer", nullable: false)
|
id = table.Column<int>(type: "integer", nullable: false)
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
request_type_id = table.Column<int>(type: "integer", nullable: false),
|
request_type_id = table.Column<int>(type: "integer", nullable: false),
|
||||||
number = table.Column<int>(type: "integer", nullable: false),
|
number = table.Column<int>(type: "integer", nullable: false),
|
||||||
site_url = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
|
||||||
list_id = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
list_id = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||||
title = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
title = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("pk_request_type_lists", x => x.id);
|
table.PrimaryKey("pk_request_list_partitions", x => x.id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "fk_request_type_lists_request_types_request_type_id",
|
name: "fk_request_list_partitions_request_types_request_type_id",
|
||||||
column: x => x.request_type_id,
|
column: x => x.request_type_id,
|
||||||
principalTable: "request_types",
|
principalTable: "request_types",
|
||||||
principalColumn: "id",
|
principalColumn: "id",
|
||||||
@@ -43,8 +42,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_request_type_lists_request_type_id_number",
|
name: "ix_request_list_partitions_request_type_id_number",
|
||||||
table: "request_type_lists",
|
table: "request_list_partitions",
|
||||||
columns: new[] { "request_type_id", "number" },
|
columns: new[] { "request_type_id", "number" },
|
||||||
unique: true);
|
unique: true);
|
||||||
}
|
}
|
||||||
@@ -53,10 +52,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "request_type_lists");
|
name: "request_list_partitions");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "list_provisioning_started_at",
|
name: "list_partitioning_started_at",
|
||||||
table: "request_types");
|
table: "request_types");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+48
-54
@@ -22,6 +22,48 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<int>("Number")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("number");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("title");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_list_partitions");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "Number")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
|
||||||
|
|
||||||
|
b.ToTable("request_list_partitions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -40,9 +82,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(512)")
|
.HasColumnType("character varying(512)")
|
||||||
.HasColumnName("list_id");
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("ListProvisioningStartedAt")
|
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("list_provisioning_started_at");
|
.HasColumnName("list_partitioning_started_at");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -77,54 +119,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("request_types", (string)null);
|
b.ToTable("request_types", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<string>("ListId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(512)
|
|
||||||
.HasColumnType("character varying(512)")
|
|
||||||
.HasColumnName("list_id");
|
|
||||||
|
|
||||||
b.Property<int>("Number")
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("number");
|
|
||||||
|
|
||||||
b.Property<int>("RequestTypeId")
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("request_type_id");
|
|
||||||
|
|
||||||
b.Property<string>("SiteUrl")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(512)
|
|
||||||
.HasColumnType("character varying(512)")
|
|
||||||
.HasColumnName("site_url");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.HasMaxLength(512)
|
|
||||||
.HasColumnType("character varying(512)")
|
|
||||||
.HasColumnName("title");
|
|
||||||
|
|
||||||
b.HasKey("Id")
|
|
||||||
.HasName("pk_request_type_lists");
|
|
||||||
|
|
||||||
b.HasIndex("RequestTypeId", "Number")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("ix_request_type_lists_request_type_id_number");
|
|
||||||
|
|
||||||
b.ToTable("request_type_lists", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -264,14 +258,14 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("settings", (string)null);
|
b.ToTable("settings", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
.WithMany("Lists")
|
.WithMany("ListPartitions")
|
||||||
.HasForeignKey("RequestTypeId")
|
.HasForeignKey("RequestTypeId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_request_type_lists_request_types_request_type_id");
|
.HasConstraintName("fk_request_list_partitions_request_types_request_type_id");
|
||||||
|
|
||||||
b.Navigation("RequestType");
|
b.Navigation("RequestType");
|
||||||
});
|
});
|
||||||
@@ -302,7 +296,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Lists");
|
b.Navigation("ListPartitions");
|
||||||
|
|
||||||
b.Navigation("Routes");
|
b.Navigation("Routes");
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-6
@@ -9,10 +9,10 @@ namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Блокировка провижининга — единственное, что не даёт двум фоновым задачам создать
|
/// Блокировка провижининга — единственное, что не даёт двум фоновым задачам создать
|
||||||
/// по списку каждая, поэтому проверяется именно на живом Postgres: смысл здесь несёт
|
/// по партиции каждая, поэтому проверяется именно на живом Postgres: смысл здесь
|
||||||
/// атомарность условного UPDATE, а не код на C#.
|
/// несёт атомарность условного UPDATE, а не код на C#.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RequestTypeProvisioningLockTests(PostgresDatabaseFixture fixture)
|
public sealed class ListPartitioningLockTests(PostgresDatabaseFixture fixture)
|
||||||
: IClassFixture<PostgresDatabaseFixture>
|
: IClassFixture<PostgresDatabaseFixture>
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30);
|
private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30);
|
||||||
@@ -59,7 +59,7 @@ public sealed class RequestTypeProvisioningLockTests(PostgresDatabaseFixture fix
|
|||||||
// Action
|
// Action
|
||||||
using (var scope = _provider.CreateScope())
|
using (var scope = _provider.CreateScope())
|
||||||
{
|
{
|
||||||
await scope.ServiceProvider.GetRequiredService<RequestTypeProvisioningLock>()
|
await scope.ServiceProvider.GetRequiredService<ListPartitioningLock>()
|
||||||
.ReleaseAsync(requestTypeId, CancellationToken.None);
|
.ReleaseAsync(requestTypeId, CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ public sealed class RequestTypeProvisioningLockTests(PostgresDatabaseFixture fix
|
|||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ApprovalRouteDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ApprovalRouteDbContext>();
|
||||||
await db.Database.ExecuteSqlInterpolatedAsync(
|
await db.Database.ExecuteSqlInterpolatedAsync(
|
||||||
$"UPDATE request_types SET list_provisioning_started_at = now() - interval '1 hour' WHERE id = {requestTypeId}");
|
$"UPDATE request_types SET list_partitioning_started_at = now() - interval '1 hour' WHERE id = {requestTypeId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -97,7 +97,7 @@ public sealed class RequestTypeProvisioningLockTests(PostgresDatabaseFixture fix
|
|||||||
private async Task<bool> TryAcquireAsync(int requestTypeId, TimeSpan? staleAfter = null)
|
private async Task<bool> TryAcquireAsync(int requestTypeId, TimeSpan? staleAfter = null)
|
||||||
{
|
{
|
||||||
using var scope = _provider.CreateScope();
|
using var scope = _provider.CreateScope();
|
||||||
return await scope.ServiceProvider.GetRequiredService<RequestTypeProvisioningLock>()
|
return await scope.ServiceProvider.GetRequiredService<ListPartitioningLock>()
|
||||||
.TryAcquireAsync(requestTypeId, staleAfter ?? StaleAfter, CancellationToken.None);
|
.TryAcquireAsync(requestTypeId, staleAfter ?? StaleAfter, CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user