food-market/src/food-market.api/Infrastructure/DocumentNumberRetry.cs
nns 9bd4375ae4
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (push) Waiting to run
Docker Public / Deploy Public on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(s18): TODO cleanup — P0 race fix + helpTooltip + whats-new + contrast + currency + audit filters + notifications
7 пунктов cleanup-спринта:

1. P0: race в GenerateNumberAsync — DocumentNumberRetry helper с
   WithOrgAdvisoryLockAsync (pg_advisory_xact_lock per orgHash/docTypeHash)
   + SaveWithRetryAsync exponential backoff. RetailSalesController POST
   обёрнут в lock. После — 23505 errors 53% → 0 на k6 baseline-replay.

2. HelpTooltip integration — ListPageShell расширен `helpTopic` пропом.
   Применено к 4 страницам (Promotions, Loyalty×2, AuditLog) + inline
   на MoySkladImportPage.

3. WhatsNewBanner — узкий emerald-toast сверху AppLayout. Опрашивает
   /api/whats-new (staleTime=1h), сравнивает buildVersion с
   localStorage.fm.lastSeenBuildVersion. Dismiss сохраняет версию.

4. Color contrast sweep — text-slate-400 в body-text узлах (empty-state,
   table-cells, hints, help) заменён на text-slate-500 dark:text-slate-400.
   19 файлов. Иконки оставлены (decorative, не покрыты axe color-contrast).

5. useFormatCurrency() хук в lib/useFormatCurrency.ts. Берёт
   defaultCurrencySymbol из useOrgSettings + локаль из i18next.
   DashboardWidgets (TopProducts/RecentSales/Margin) переведены — `₸`
   больше не захардкоден.

6. Audit log UI filters — OrgAuditLogPage расширен полями «Кто»
   (Select сотрудников), «Дата с» / «по» (date-input'ы), кнопка
   «Сбросить фильтры». Backend уже умел эти параметры.

7. NotificationCenter — bell-icon в sidebar footer'е с unread badge,
   popover с 30 последних событий (Sale/Supply/LowStock через
   useNotificationsHub). Each item clickable → документ. In-memory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 18:50:35 +05:00

105 lines
5.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace foodmarket.Api.Infrastructure;
/// <summary>Sprint 18: устранение P0-race в <c>GenerateNumberAsync</c>.
///
/// <para><b>Проблема (Sprint 14 baseline)</b>: при параллельных POSTах
/// двух разных кассиров одна и та же org получала read-modify-write race —
/// оба читали `lastNumber = ПР-2026-000010`, оба ставили seq=11, оба
/// INSERT'или → unique-violation 23505 на индексе
/// <c>IX_retail_sales_OrganizationId_Number</c>. k6 показал 53% failure rate
/// при 5 параллельных VU на одной orgе.</para>
///
/// <para><b>Решение</b>: retry-loop вокруг SaveChanges. Если ловим
/// PostgresException 23505 (unique_violation) на индексе с Number в
/// имени — берём query-фабрику Number'а и пересчитываем заново
/// (последний инкрементнулся другим конкурентом → следующий
/// свободен). До 5 попыток с микро-jitter'ом (0-50ms) чтобы
/// эффективно расходиться.</para>
///
/// <para>Использование в Post-методах:
/// <code>
/// var saved = await DocumentNumberRetry.SaveWithRetryAsync(
/// _db,
/// ct,
/// async () => sale.Number = await GenerateNumberAsync(sale.Date, ct));
/// </code></para></summary>
public static class DocumentNumberRetry
{
/// <summary>Сохраняет ChangeTracker'овские изменения с retry на
/// 23505 unique-violation. <paramref name="regenerateNumber"/>
/// вызывается перед каждой попыткой — он должен пересчитать
/// Number-поле затронутой сущности (например,
/// `sale.Number = await GenerateNumberAsync(...)`). На каждой
/// retry'ой попытке регенерация подхватит свежий `lastNumber`
/// (под предположением что предыдущий конкурент уже закоммитил).</summary>
public static async Task SaveWithRetryAsync(
AppDbContext db,
Func<Task> 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;
}
}
}
/// <summary>Альтернативный паттерн: PostgreSQL advisory-lock для
/// сериализации Number-генерации в рамках org+document-type. В отличие
/// от retry, advisory-lock даёт O(1) попытки, но добавляет lock-wait
/// (несколько мс) при contention. Использовать когда retry слишком
/// часто упирается в maxAttempts.
///
/// <paramref name="lockKeyA"/>/<paramref name="lockKeyB"/> — int32-хеши,
/// например (orgId.GetHashCode(), docType.GetHashCode()). Lock
/// автоматически снимается на конце текущей транзакции.</summary>
public static async Task WithOrgAdvisoryLockAsync(
AppDbContext db, int lockKeyA, int lockKeyB,
Func<Task> 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);
}
/// <summary>Проверяет: это unique-violation на индексе с «Number»
/// в имени (наш паттерн — IX_*_OrganizationId_Number).</summary>
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);
}
}