food-market/tests/e2e/reports/bugs/bug-003-serializable-500.md
nns 284ad095c1
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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
fix(s23): adversarial bug-hunt — 4 bugs found, all fixed
Sprint 23 (adversarial): атаковали систему как недоброжелатель.
Найдено 4 бага, все починены.

Bug #001 (Medium): NULL-byte в Product.Name вызывал 500 без тела.
  Postgres TEXT не принимает \x00. Добавил NoControlChars() в
  ProductInputValidator + CounterpartyInputValidator.

Bug #002 (Low): ProductInputValidator MaximumLength(200) конфликтовал
  со StringLength(500) в DTO и schema HasMaxLength(500). Сделал 500
  везде. Counterparty: 200 → 255 (matches HasMaxLength).

Bug #003 (CRITICAL): параллельные posting'и под Serializable выбрасывали
  PostgresException 40001 → middleware → 500 empty body. Добавил
  SerializationConflictMiddleware который мапит 40001 → 409 Conflict
  с {error, retryable: true}. Также SerializableRetry helper для
  явного retry внутри endpoint'ов с exp backoff. Применил retry-wrap
  к RetailSalesController.Post (PostCoreAsync extracted).

Bug #004 (Low): цена 0.0000001 округлялась до 0 уже после прохождения
  required-price check (check был ДО RoundIfNeeded). FindMissing-
  RequiredPriceAsync теперь округляет перед сравнением — required
  цена реально > 0 после rounding.

Bug reports: tests/e2e/reports/bugs/bug-00[1-4]-*.md (github-issue format).

Multi-tenant attacks (cat 3): clean — все cross-org GET/PUT/DELETE
дают 404, bulk-update affected=0, lists не утекают.
Auth-edge (cat 2): clean — JWT tampering 401, garbage 401, CORS evil.com
не получает allow-origin, fake refresh 400 invalid_grant.
DOS (cat 7): clean — 50MB body 413, 200 headers 431, long URL 200.
Hangfire safety (cat 8): clean — regular Admin → /hangfire 403,
  seed-demo использует tenant context, body org-id игнорируется.

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

3.5 KiB
Raw Permalink Blame History

Bug #003 — Serializable conflict → 500 (нужно 409 + retry)

Severity: CRITICAL (visible incorrect HTTP-код на race; кассир видит «ошибка сервера» вместо корректного «попробуй ещё раз»)
Component: RetailSalesController.Post (и любой posting endpoint, который BeginTransactionAsync(Serializable))
Found: Sprint 23, 2026-06-08

Воспроизведение

20 параллельных POST /api/sales/retail/{id}/post на один продукт при stock=10:

ok=8 conflict409=0 500=12  (12 пятисоток с empty body)

Stock-инвариант сохраняется (stock=2.0 == Σ movements), over-sell'a НЕТ ✓. Но 500 + empty body — это серверная ошибка, кассиру нечего сказать «попробуй ещё раз», UI показывает «Ошибка сервера».

Причина

В RetailSalesController.Post:

await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);

await tx.CommitAsync(ct);  // ← здесь Postgres может бросить 40001

PostgreSQL Serializable выбрасывает SqlState=40001 при serialization failure (другая транзакция изменила данные, которые мы читали). EF Core ловит как PostgresException, прокидывает наверх; глобальный middleware возвращает 500 без тела.

Фикс

Wrap commit + retry. Маленький helper WithSerializableRetryAsync:

const int maxAttempts = 5;
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
        await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
        // … work …
        await tx.CommitAsync(ct);
        return Ok();
    } catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == "40001") {
        // Serialization conflict — retry с exp backoff + jitter.
        if (attempt == maxAttempts)
            return Conflict(new { error = "Слишком много одновременных продаж этого товара. Попробуйте ещё раз." });
        await Task.Delay(TimeSpan.FromMilliseconds(50 * attempt + Random.Shared.Next(50)), ct);
    } catch (PostgresException pg) when (pg.SqlState == "40001") {
        // Аналогично для исключения вне DbUpdateException обёртки.
        if (attempt == maxAttempts) return Conflict(new { error = "Слишком много одновременных продаж этого товара. Попробуйте ещё раз." });
        await Task.Delay(TimeSpan.FromMilliseconds(50 * attempt + Random.Shared.Next(50)), ct);
    }
}

После фикса: те же 20 параллельных постов → большинство retry'ятся и проходят, остаток возвращает 409 (а не 500) с понятным сообщением. Stock-инвариант по-прежнему сохраняется (это другое свойство).

Retest

После фикса — те же 20 параллельных POST:

  • ok = 10 (полное число доступных единиц)
  • 409 = 10 (over-stock или retry-exhausted, с понятным сообщением)
  • 500 = 0 ✓
  • stock-invariant: 0 == 0 ✓