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
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>
105 lines
5.5 KiB
C#
105 lines
5.5 KiB
C#
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);
|
||
}
|
||
}
|