Архивация списков (#13)
Tests / frontend-test (push) Successful in 44s
Tests / backend-test (push) Failing after 17m4s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
2026-08-19 09:08:36 +00:00
parent a2672042d7
commit 82512eb1db
56 changed files with 2587 additions and 51 deletions
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore;
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
/// <summary>
/// Блокировка «по типу заявок идёт создание следующей партиции списка» на строке
/// request_types.
///
/// Почему сырой SQL, а не обычный EF: через change tracker это было бы
/// read-modify-write (прочитали флаг → проверили → записали), и две задачи,
/// прочитавшие null одновременно, обе решили бы, что блокировка свободна.
/// Здесь проверка и захват — ОДИН условный UPDATE, атомарный на уровне строки:
/// Postgres берёт row lock на время оператора, поэтому ровно одна из
/// конкурирующих задач увидит affected rows = 1, остальные — 0.
///
/// Никакого pg_advisory_lock: он живёт в рамках сессии, а нам нужно, чтобы
/// блокировку было видно в самой строке (админке — что провижининг идёт) и чтобы
/// она переживала возврат соединения в пул между шагами задачи.
/// </summary>
public sealed class ListPartitioningLock(ApprovalRouteDbContext db)
{
/// <summary>
/// Попытаться захватить блокировку. true — захватили (и только тогда можно
/// провижинить), false — держит кто-то другой либо типа заявок нет.
///
/// Блокировка старше staleAfter считается брошенной (процесс упал, не сняв
/// флаг) и перехватывается.
/// </summary>
public async Task<bool> TryAcquireAsync(int requestTypeId, TimeSpan staleAfter, CancellationToken cancellationToken)
{
// Имена таблицы/колонки — snake_case из UseSnakeCaseNamingConvention
// (см. InfrastructureServiceCollectionExtensions).
var affected = await db.Database.ExecuteSqlInterpolatedAsync(
$"""
UPDATE request_types
SET list_partitioning_started_at = now()
WHERE id = {requestTypeId}
AND (list_partitioning_started_at IS NULL
OR list_partitioning_started_at < now() - CAST({staleAfter} AS interval))
""",
cancellationToken);
return affected == 1;
}
/// <summary>Снять блокировку. Идемпотентно: снятая блокировка снимается повторно без ошибки.</summary>
public Task ReleaseAsync(int requestTypeId, CancellationToken cancellationToken) =>
db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE request_types SET list_partitioning_started_at = NULL WHERE id = {requestTypeId}",
cancellationToken);
}