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); } }