using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace foodmarket.Api.Infrastructure;
/// Sprint 18: устранение P0-race в GenerateNumberAsync.
///
/// Проблема (Sprint 14 baseline): при параллельных POSTах
/// двух разных кассиров одна и та же org получала read-modify-write race —
/// оба читали `lastNumber = ПР-2026-000010`, оба ставили seq=11, оба
/// INSERT'или → unique-violation 23505 на индексе
/// IX_retail_sales_OrganizationId_Number. k6 показал 53% failure rate
/// при 5 параллельных VU на одной orgе.
///
/// Решение: retry-loop вокруг SaveChanges. Если ловим
/// PostgresException 23505 (unique_violation) на индексе с Number в
/// имени — берём query-фабрику Number'а и пересчитываем заново
/// (последний инкрементнулся другим конкурентом → следующий
/// свободен). До 5 попыток с микро-jitter'ом (0-50ms) чтобы
/// эффективно расходиться.
///
/// Использование в Post-методах:
///
/// var saved = await DocumentNumberRetry.SaveWithRetryAsync(
/// _db,
/// ct,
/// async () => sale.Number = await GenerateNumberAsync(sale.Date, ct));
///
public static class DocumentNumberRetry
{
/// Сохраняет ChangeTracker'овские изменения с retry на
/// 23505 unique-violation.
/// вызывается перед каждой попыткой — он должен пересчитать
/// Number-поле затронутой сущности (например,
/// `sale.Number = await GenerateNumberAsync(...)`). На каждой
/// retry'ой попытке регенерация подхватит свежий `lastNumber`
/// (под предположением что предыдущий конкурент уже закоммитил).
public static async Task SaveWithRetryAsync(
AppDbContext db,
Func regenerateNumber,
CancellationToken ct = default,
int maxAttempts = 10)
{
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
if (attempt > 0)
{
// Exponential-ish backoff с jitter: 10-60ms, 20-90ms, 40-130ms, …
// С jitter параллельные VU расходятся по времени и не конфликтуют
// снова на следующей попытке.
var baseDelay = attempt * 10;
await Task.Delay(baseDelay + Random.Shared.Next(0, 30 + attempt * 10), ct);
await regenerateNumber();
}
try
{
await db.SaveChangesAsync(ct);
return;
}
catch (DbUpdateException ex) when (IsUniqueViolationOnNumber(ex))
{
if (attempt + 1 >= maxAttempts) throw;
continue;
}
}
}
/// Альтернативный паттерн: PostgreSQL advisory-lock для
/// сериализации Number-генерации в рамках org+document-type. В отличие
/// от retry, advisory-lock даёт O(1) попытки, но добавляет lock-wait
/// (несколько мс) при contention. Использовать когда retry слишком
/// часто упирается в maxAttempts.
///
/// / — int32-хеши,
/// например (orgId.GetHashCode(), docType.GetHashCode()). Lock
/// автоматически снимается на конце текущей транзакции.
public static async Task WithOrgAdvisoryLockAsync(
AppDbContext db, int lockKeyA, int lockKeyB,
Func action, CancellationToken ct = default)
{
// pg_advisory_xact_lock(int, int) — освобождается на COMMIT/ROLLBACK
// текущей транзакции автоматически. Без явной транзакции —
// EF держит implicit (за SaveChanges), но lock тогда снимается сразу.
await using var tx = await db.Database.BeginTransactionAsync(ct);
await db.Database.ExecuteSqlRawAsync(
"SELECT pg_advisory_xact_lock({0}, {1})",
new object[] { lockKeyA, lockKeyB }, ct);
await action();
await tx.CommitAsync(ct);
}
/// Проверяет: это unique-violation на индексе с «Number»
/// в имени (наш паттерн — IX_*_OrganizationId_Number).
private static bool IsUniqueViolationOnNumber(DbUpdateException ex)
{
if (ex.InnerException is not PostgresException pg) return false;
if (pg.SqlState != "23505") return false;
// ConstraintName выглядит как "IX_retail_sales_OrganizationId_Number"
// или null в некоторых случаях (для PG ≥ 9 обычно есть).
var name = pg.ConstraintName ?? "";
return name.Contains("Number", StringComparison.OrdinalIgnoreCase);
}
}