using Microsoft.EntityFrameworkCore; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; /// /// Блокировка «по типу заявок идёт создание следующей партиции списка» на строке /// request_types. /// /// Почему сырой SQL, а не обычный EF: через change tracker это было бы /// read-modify-write (прочитали флаг → проверили → записали), и две задачи, /// прочитавшие null одновременно, обе решили бы, что блокировка свободна. /// Здесь проверка и захват — ОДИН условный UPDATE, атомарный на уровне строки: /// Postgres берёт row lock на время оператора, поэтому ровно одна из /// конкурирующих задач увидит affected rows = 1, остальные — 0. /// /// Никакого pg_advisory_lock: он живёт в рамках сессии, а нам нужно, чтобы /// блокировку было видно в самой строке (админке — что провижининг идёт) и чтобы /// она переживала возврат соединения в пул между шагами задачи. /// public sealed class ListPartitioningLock(ApprovalRouteDbContext db) { /// /// Попытаться захватить блокировку. true — захватили (и только тогда можно /// провижинить), false — держит кто-то другой либо типа заявок нет. /// /// Блокировка старше staleAfter считается брошенной (процесс упал, не сняв /// флаг) и перехватывается. /// public async Task 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; } /// Снять блокировку. Идемпотентно: снятая блокировка снимается повторно без ошибки. public Task ReleaseAsync(int requestTypeId, CancellationToken cancellationToken) => db.Database.ExecuteSqlInterpolatedAsync( $"UPDATE request_types SET list_partitioning_started_at = NULL WHERE id = {requestTypeId}", cancellationToken); }