Ef Core

Конкурентність та Блокування (Частина 1)

Глибокий розбір конкурентності в EF Core — Read Phenomena (dirty read, non-repeatable read, phantom read), MVCC у PostgreSQL vs Lock-based у SQL Server, практичні сценарії race conditions та їх вирішення через правильні стратегії блокування.

Конкурентність та Блокування

Конкурентність — фундаментальна проблема OLTP систем

У попередній статті ми розглянули ConcurrencyToken і RowVersion як інструменти. Тепер — крок назад, щоб зрозуміти чому ці проблеми взагалі виникають і які механізми бази даних їх породжують.

Кожен веб-застосунок, що обслуговує більше одного користувача одночасно, стикається з конкурентністю. Сотні HTTP-запитів виконуються паралельно, кожен зі своїм DbContext, своєю транзакцією. І всі вони звертаються до спільних рядків у базі даних.

Без правильної координації виникають феномени конкурентного читання — ситуації коли паралельні транзакції «бачать» нераціональний стан даних. Стандарт SQL визначає чотири таких феномени, і кожен IsolationLevel захищає від певної підмножини з них.

Важливіше — розуміти ці феномени не через визначення з підручника, а через конкретні бізнес-сценарії: що саме може піти не так у вашому застосунку, і яку ціну за це платить користувач.


Read Phenomena: чотири аномалії конкурентності

Dirty Read: читання незафіксованих змін

Dirty Read — транзакція читає дані, які ще не зафіксовані іншою транзакцією. Якщо та транзакція відкочується — перша прочитала дані, яких ніколи не існувало.

Класичний сценарій: банківський переказ.

Транзакція A (Переказ):
  1. UPDATE Accounts SET Balance = Balance - 1000 WHERE Id = 1
     (Баланс: 5000 → 4000, але транзакція ще не закрита)

Транзакція B (Читання балансу) — ReadUncommitted:
  2. SELECT Balance FROM Accounts WHERE Id = 1
     → Повертає 4000 (незафіксовані дані від A!)

Транзакція A:
  3. ROLLBACK  (помилка у переказі — відміна)
     (Баланс повертається до 5000)

Транзакція B вже показала клієнту 4000 — цього ніколи не було!

EF Core захист: будь-який IsolationLevel вище ReadUncommitted захищає від dirty read. Ніколи не використовуйте ReadUncommitted для даних, що впливають на бізнес-рішення.

Non-Repeatable Read: різні значення при двох читаннях

Non-Repeatable Read — транзакція читає один рядок двічі і отримує різні значення, бо інша транзакція змінила його між цими читаннями.

Транзакція A (Перевірка і розрахунок):
  1. SELECT Price FROM Products WHERE Id = 5  → Price = 10000
     (Обчислення суми замовлення: 3 × 10000 = 30000)

Транзакція B (Зміна ціни):
  2. UPDATE Products SET Price = 9000 WHERE Id = 5
  3. COMMIT

Транзакція A:
  4. SELECT Price FROM Products WHERE Id = 5  → Price = 9000 (інше!)
     (Підтвердження ціни: невідповідність із кроком 1)

Реальний наслідок: замовлення виставлене за ціною 10000, а при повторній перевірці ціна вже 9000. Система показує неузгоджені дані в одному бізнес-процесі.

Захист: RepeatableRead або вище. ReadCommitted не захищає.

Phantom Read: нові рядки між двома запитами

Phantom Read — транзакція виконує один і той самий SELECT двічі і отримує різну кількість рядків, бо інша транзакція вставила або видалила рядки.

Транзакція A (Звіт):
  1. SELECT COUNT(*) FROM Orders WHERE Status='Pending'  → 10
     (Підготовка до обробки: 10 замовлень)

Транзакція B:
  2. INSERT INTO Orders (Status) VALUES ('Pending')  ← нове замовлення
  3. COMMIT

Транзакція A:
  4. SELECT * FROM Orders WHERE Status='Pending'  → 11 рядків!
     (Неочікуваний «фантом» — замовлення #11 не існувало при кроці 1)

Захист: Serializable. RepeatableRead захищає від змін існуючих рядків, але не від нових.

Write Skew: найскладніша аномалія

Write Skew не впливає на читання, але стосується логіки запису: дві транзакції читають один стан, кожна приймає рішення що «виглядає правильним» на основі прочитаного, і обидві записують — разом порушуючи інваріант системи.

Бізнес-правило: у черговій бригаді має бути хоча б 1 лікар.
Зараз: Doctor_A.OnShift = true, Doctor_B.OnShift = true

Транзакція A (Doctor_A хоче відпросити):
  1. SELECT COUNT(*) FROM Doctors WHERE OnShift = true  → 2
     (Можна — залишиться ще Doctor_B)
  2. UPDATE Doctors SET OnShift = false WHERE Id = A (ЩЕ НЕ COMMIT)

Транзакція B (Doctor_B хоче відпросити — одночасно!):
  3. SELECT COUNT(*) FROM Doctors WHERE OnShift = true  → 2 (ще бачить A як OnShift)
     (Теж можна — залишиться Doctor_A)
  4. UPDATE Doctors SET OnShift = false WHERE Id = B (ЩЕ НЕ COMMIT)

Транзакція A: COMMIT
Транзакція B: COMMIT

Результат: обидва лікарі OnShift = false — інваріант порушено!
Не Dirty Read, не Phantom, не Non-Repeatable — кожна транзакція «бачила» коректний стан.

Захист від Write Skew: Serializable або Pessimistic Locking (явне блокування при читанні).


MVCC vs Lock-based: дві філософії

Різні СУБД вирішують проблему конкурентності по-різному. Розуміння цієї різниці допомагає обирати правильні стратегії.

Lock-based (SQL Server за замовчуванням)

SQL Server у режимі ReadCommitted (за замовчуванням) використовує блокування читання: при SELECT рядок блокується на час читання, при UPDATE — на час транзакції. Читачі чекають на письменників і навпаки.

Читач    → SELECT * FROM Products WHERE Id=1  → SHARED LOCK (на час читання)
Письменник → UPDATE Products WHERE Id=1       → EXCLUSIVE LOCK (чекає на Shared)

Або:
Письменник → UPDATE Products WHERE Id=1       → EXCLUSIVE LOCK
Читач      → SELECT * FROM Products WHERE Id=1 → чекає на Exclusive!

Це може призвести до Reader-Writer Contention: велика кількість читачів блокує записи і навпаки.

Вирішення у SQL Server: RCSI (Read Committed Snapshot Isolation). При увімкненні — читачі ніколи не чекають на письменників. Вони читають «знімок» даних на момент початку запиту. Це поведінка ближча до MVCC:

-- Увімкнути RCSI для бази:
ALTER DATABASE YourDb SET READ_COMMITTED_SNAPSHOT ON;

Після цього ReadCommitted у SQL Server поводиться як Snapshot Isolation — без блокувань при читанні.

MVCC (PostgreSQL, MySQL InnoDB)

Multi-Version Concurrency Control — PostgreSQL зберігає кілька версій кожного рядка. Кожна транзакція «бачить» snapshot даних на момент свого початку. Читачі ніколи не блокують письменників і навпаки.

Рядок Products.Id=1 в PostgreSQL (версії через xmin/xmax):
  [xmin=100, xmax=null,  Price=10000]  ← активна версія
  [xmin=95,  xmax=100,   Price=9500]   ← старіша версія (для старих транзакцій)

Транзакція B (xid=102, почала після 100):  бачить Price=10000
Транзакція A (xid=98,  почала до 100):     бачить Price=9500

Переваги MVCC:

  • Читання ніколи не блокується записом
  • Висока продуктивність при mixed read/write навантаженні
  • RepeatableRead у PostgreSQL захищає навіть від Phantom Read (на відміну від стандарту SQL)

Недолік: VACUUM — PostgreSQL має прибирати застарілі версії рядків. При надмірній кількості UPDATE без VACUUM — «bloat» і деградація продуктивності.

Порівняльна таблиця

SQL Server (Lock)PostgreSQL (MVCC)
Читачі блокують письменниківТак (без RCSI)Ні
Письменники блокують читачівТакНі (readers see snapshot)
Overhead при читанніНизькийВерсійний overhead
Overhead при записіСередній (locks)Вищий (версіонування)
Deadlock ймовірністьВищаНижча
Vacuum потрібенНіТак

Практичні Race Conditions у EF Core

Тепер розглянемо конкретні race conditions що трапляються у реальних застосунках на EF Core і їх вирішення.

Race Condition 1: Check-Then-Act

Найпоширеніший паттерн: перевірити умову, прийняти рішення, виконати дію — але між перевіркою і дією стан змінився:

// ❌ НЕБЕЗПЕЧНО: Race Condition між AnyAsync і Add
public async Task RegisterUserAsync(string email)
{
    // Крок 1: Перевірка унікальності
    bool exists = await context.Users.AnyAsync(u => u.Email == email);
    // ← Між тут і кроком 2 інша транзакція може вставити того ж email!

    if (exists)
        throw new BusinessException("Email вже зареєстрований");

    // Крок 2: Вставка
    context.Users.Add(new User { Email = email });
    await context.SaveChangesAsync(); // ← Дублікат можливий!
}

Рішення A: Unique Index + обробка виключення (найефективніше):

// UNIQUE INDEX у міграції:
builder.HasIndex(u => u.Email).IsUnique();

public async Task RegisterUserAsync(string email)
{
    context.Users.Add(new User { Email = email });

    try
    {
        await context.SaveChangesAsync();
    }
    catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
    {
        throw new BusinessException("Email вже зареєстрований");
    }
}

private static bool IsUniqueConstraintViolation(DbUpdateException ex)
{
    return ex.InnerException is SqlException sqlEx &&
           (sqlEx.Number == 2627 || sqlEx.Number == 2601); // SQL Server
    // або PostgreSQL: ex.InnerException is PostgresException pgEx && pgEx.SqlState == "23505"
}

Рішення B: Optimistic Concurrency (якщо немає Unique Index):

// Перевіряємо і вставляємо — якщо між ними вставили дублікат,
// UNIQUE INDEX спіймає і SaveChanges кине виключення

Race Condition 2: Double Spending (інвентаризація)

// ❌ НЕБЕЗПЕЧНО: Read-Modify-Write без захисту
public async Task ReserveProductAsync(int productId, int quantity)
{
    var product = await context.Products.FindAsync(productId);
    // ← Між Read і Write 10 паралельних запитів можуть прочитати Stock=5

    if (product!.Stock < quantity)
        throw new InsufficientStockException();

    product.Stock -= quantity; // 5 - 1 = 4 (всі 10 прочитали 5 і всі пишуть 4!)
    await context.SaveChangesAsync();
    // Результат: Stock = 4, але мало б бути 5 - 10 = -5!
}

Вирішення через Optimistic Concurrency:

public async Task ReserveProductAsync(int productId, int quantity)
{
    // Зовнішній retry loop
    while (true)
    {
        var product = await context.Products.FindAsync(productId);

        if (product!.Stock < quantity)
            throw new InsufficientStockException();

        product.Stock -= quantity;

        try
        {
            await context.SaveChangesAsync();
            return; // Успіх
        }
        catch (DbUpdateConcurrencyException)
        {
            // Хтось інший змінив Stock → перезавантажити і повторити
            await context.Entry(product).ReloadAsync();
            // Якщо тепер Stock < quantity → InsufficientStockException на наступній ітерації
        }
    }
}

Вирішення через атомарний UPDATE:

// Атомарний UPDATE: якщо Stock >= quantity — зменшити, інакше — нічого
public async Task ReserveProductAsync(int productId, int quantity)
{
    int rowsAffected = await context.Products
        .Where(p => p.Id == productId && p.Stock >= quantity)
        .ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - quantity));

    if (rowsAffected == 0)
    {
        // Або продукт не існує, або Stock недостатній
        var product = await context.Products.FindAsync(productId);
        if (product is null) throw new NotFoundException();
        throw new InsufficientStockException($"Available: {product.Stock}");
    }
}
// SQL: UPDATE Products SET Stock = Stock - @qty WHERE Id = @id AND Stock >= @qty
// Атомарна операція: перевірка і зміна в одному SQL — race condition неможливий!

Race Condition 3: Duplicate Insert (ідемпотентність)

// ❌ НЕБЕЗПЕЧНО: подвійний POST (мережева помилка → retry від клієнта)
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder(CreateOrderDto dto)
{
    var order = new Order { CustomerId = dto.CustomerId, ... };
    context.Orders.Add(order);
    await context.SaveChangesAsync();
    return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
    // Якщо відповідь не дійшла → клієнт retries → дублікат Order!
}

Вирішення: Idempotency Key:

// Клієнт генерує унікальний ключ запиту і надсилає у заголовку
// X-Idempotency-Key: "a1b2c3d4-..."

[HttpPost("orders")]
public async Task<IActionResult> CreateOrder(
    CreateOrderDto dto,
    [FromHeader(Name = "X-Idempotency-Key")] Guid idempotencyKey)
{
    // Перевірити чи вже обробляли цей ключ
    var existing = await context.IdempotencyKeys
        .AsNoTracking()
        .FirstOrDefaultAsync(k => k.Key == idempotencyKey);

    if (existing is not null)
    {
        // Повернути той самий результат що і першого разу
        var existingOrder = await context.Orders.FindAsync(existing.ResultId);
        return CreatedAtAction(nameof(GetOrder), new { id = existingOrder!.Id }, existingOrder);
    }

    await using var tx = await context.Database.BeginTransactionAsync();

    var order = new Order { ... };
    context.Orders.Add(order);
    await context.SaveChangesAsync();

    // Зберегти ключ разом з Id результату
    context.IdempotencyKeys.Add(new IdempotencyKey
    {
        Key      = idempotencyKey,
        ResultId = order.Id,
        ExpiresAt = DateTime.UtcNow.AddDays(7)
    });
    await context.SaveChangesAsync();

    await tx.CommitAsync();

    return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}

ETag Pattern для REST API

HTTP стандарт визначає заголовок ETag (Entity Tag) — унікальний ідентифікатор версії ресурсу. Браузери і клієнти можуть кешувати відповідь і при наступному запиті передавати If-None-Match (для умовного GET) або If-Match (для умовного PUT/DELETE). Це природний спосіб реалізувати Optimistic Concurrency у REST API без «протікання» деталей БД у клієнтський код.

Як ETags пов'язані з RowVersion

RowVersion (байтовий масив) або Version (Guid) — природні джерела ETag значення. Клієнт отримує ETag у відповіді, зберігає його і при PUT відправляє назад. Сервер порівнює з поточним значенням у БД:

// Хелпер для перетворення RowVersion в ETag рядок
public static class ETagHelper
{
    // RowVersion byte[] → ETag рядок (Base64)
    public static string ToETag(byte[] rowVersion)
        => $"\"{Convert.ToBase64String(rowVersion)}\"";

    // ETag рядок → RowVersion byte[]
    public static byte[] FromETag(string etag)
    {
        var value = etag.Trim('"');
        return Convert.FromBase64String(value);
    }

    // Guid Version → ETag
    public static string ToETag(Guid version)
        => $"\"{version:N}\"";
}

GET: відповідь з ETag

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
    var product = await context.Products
        .AsNoTracking()
        .FirstOrDefaultAsync(p => p.Id == id);

    if (product is null) return NotFound();

    var etag = ETagHelper.ToETag(product.RowVersion);

    // Умовний GET: якщо ETag не змінився — 304 Not Modified
    if (Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch)
        && ifNoneMatch == etag)
    {
        return StatusCode(304); // Not Modified
    }

    Response.Headers.ETag = etag;

    return Ok(new ProductDto
    {
        Id    = product.Id,
        Name  = product.Name,
        Price = product.Price,
        // Не передаємо RowVersion напряму — він в ETag заголовку
    });
}

PUT з If-Match: умовне оновлення

[HttpPut("{id}")]
public async Task<IActionResult> UpdateProduct(int id, UpdateProductDto dto)
{
    // 1. Перевірити наявність If-Match заголовку
    if (!Request.Headers.TryGetValue("If-Match", out var ifMatch))
        return StatusCode(428, "If-Match header required"); // 428 Precondition Required

    // 2. Завантажити entity
    var product = await context.Products.FindAsync(id);
    if (product is null) return NotFound();

    // 3. Порівняти ETag з поточним RowVersion
    var currentETag = ETagHelper.ToETag(product.RowVersion);
    if (ifMatch != currentETag)
        return StatusCode(412, "Precondition Failed — resource was modified"); // 412

    // 4. Застосувати зміни
    product.Name  = dto.Name;
    product.Price = dto.Price;

    try
    {
        await context.SaveChangesAsync();
        // RowVersion оновлюється автоматично сервером

        Response.Headers.ETag = ETagHelper.ToETag(product.RowVersion);
        return Ok(); // або 204 No Content
    }
    catch (DbUpdateConcurrencyException)
    {
        // Конкурентна зміна між перевіркою і збереженням (race condition edge case)
        return StatusCode(412, "Concurrent modification detected");
    }
}

DELETE з If-Match: захист від видалення застарілого ресурсу

[HttpDelete("{id}")]
public async Task<IActionResult> DeleteProduct(int id)
{
    if (!Request.Headers.TryGetValue("If-Match", out var ifMatch))
        return StatusCode(428, "If-Match header required");

    var product = await context.Products.FindAsync(id);
    if (product is null) return NotFound();

    if (ETagHelper.ToETag(product.RowVersion) != ifMatch)
        return StatusCode(412, "Resource version mismatch");

    context.Products.Remove(product);
    await context.SaveChangesAsync();
    return NoContent(); // 204
}
ETag переваги: Клієнт не знає нічого про RowVersion чи Guid — він просто зберігає непрозорий рядок. HTTP стандарт чітко визначає семантику 412 Precondition Failed. Браузерний кеш автоматично використовує ETags для умовних GET — безкоштовна оптимізація.

Real-world сценарії конкурентності

Сценарій 1: Booking System (бронювання)

Система бронювання готельних номерів — класичний high-contention сценарій. Той самий номер може намагатись забронювати десяток клієнтів одночасно:

public class RoomBookingService
{
    private readonly AppDbContext _context;

    // Наївна реалізація — race condition!
    public async Task<Booking> BookRoomNaiveAsync(
        int roomId, DateOnly checkIn, DateOnly checkOut, int guestId)
    {
        // ❌ Check-Then-Act: між AnyAsync і Add → конфлікт!
        var isAvailable = !await _context.Bookings.AnyAsync(b =>
            b.RoomId      == roomId &&
            b.CheckIn     < checkOut &&
            b.CheckOut    > checkIn &&
            b.Status      != "Cancelled");

        if (!isAvailable) throw new RoomNotAvailableException();

        var booking = new Booking
        {
            RoomId    = roomId,
            GuestId   = guestId,
            CheckIn   = checkIn,
            CheckOut  = checkOut,
            Status    = "Confirmed"
        };
        _context.Bookings.Add(booking);
        await _context.SaveChangesAsync();
        return booking;
    }

    // ✅ Правильна реалізація: Unique Constraint + Exception handling
    public async Task<Booking> BookRoomSafeAsync(
        int roomId, DateOnly checkIn, DateOnly checkOut, int guestId)
    {
        // Без попередньої перевірки — покладаємось на UNIQUE CONSTRAINT:
        // ALTER TABLE Rooms ADD CONSTRAINT no_overlap
        //   EXCLUDE USING gist (RoomId WITH =, daterange(CheckIn, CheckOut) WITH &&)
        // (PostgreSQL: Exclusion Constraint для діапазонів)
        // SQL Server: через тригер або CHECK CONSTRAINT + INDEX

        var booking = new Booking
        {
            RoomId    = roomId,
            GuestId   = guestId,
            CheckIn   = checkIn,
            CheckOut  = checkOut,
            Status    = "Confirmed"
        };

        _context.Bookings.Add(booking);

        try
        {
            await _context.SaveChangesAsync();
            return booking;
        }
        catch (DbUpdateException ex) when (IsOverlapConstraintViolation(ex))
        {
            throw new RoomNotAvailableException(
                $"Room {roomId} is already booked for {checkIn:d}{checkOut:d}");
        }
    }

    // Альтернатива: Pessimistic Lock для room
    public async Task<Booking> BookRoomWithLockAsync(
        int roomId, DateOnly checkIn, DateOnly checkOut, int guestId)
    {
        await using var tx = await _context.Database.BeginTransactionAsync(
            IsolationLevel.RepeatableRead);

        // Блокуємо Room на рівні рядка
        var room = await _context.Rooms
            .FromSqlRaw("SELECT * FROM Rooms WITH (UPDLOCK, ROWLOCK) WHERE Id = {0}", roomId)
            .FirstOrDefaultAsync();

        if (room is null) throw new NotFoundException();

        // Тепер можна перевіряти без race condition (інші чекають блокування)
        var isAvailable = !await _context.Bookings.AnyAsync(b =>
            b.RoomId  == roomId &&
            b.CheckIn  < checkOut &&
            b.CheckOut > checkIn &&
            b.Status  != "Cancelled");

        if (!isAvailable)
        {
            await tx.RollbackAsync();
            throw new RoomNotAvailableException();
        }

        var booking = new Booking { RoomId = roomId, GuestId = guestId,
                                     CheckIn = checkIn, CheckOut = checkOut };
        _context.Bookings.Add(booking);
        await _context.SaveChangesAsync();
        await tx.CommitAsync();
        return booking;
    }

    private static bool IsOverlapConstraintViolation(DbUpdateException ex)
        => ex.InnerException?.Message.Contains("no_overlap") == true ||
           ex.InnerException?.Message.Contains("IX_Bookings") == true;
}

Сценарій 2: Інвентаризація — Preventing Oversell

E-commerce: захист від продажу більше ніж є на складі:

public class InventoryService
{
    private readonly AppDbContext _context;

    // ✅ Атомарний UPDATE з перевіркою залишку
    public async Task<ReservationResult> ReserveItemsAsync(
        IReadOnlyList<(int ProductId, int Quantity)> items)
    {
        var reservations = new List<StockReservation>();
        var failures     = new List<(int ProductId, int Available, int Requested)>();

        using var tx = await _context.Database.BeginTransactionAsync();

        foreach (var (productId, quantity) in items)
        {
            // Атомарний: зменшити якщо Stock достатній
            var rowsAffected = await _context.Products
                .Where(p => p.Id == productId && p.Stock >= quantity)
                .ExecuteUpdateAsync(s =>
                    s.SetProperty(p => p.Stock, p => p.Stock - quantity));

            if (rowsAffected == 1)
            {
                reservations.Add(new StockReservation
                {
                    ProductId      = productId,
                    ReservedQty    = quantity,
                    ReservedAt     = DateTime.UtcNow,
                    ExpiresAt      = DateTime.UtcNow.AddMinutes(30)
                });
            }
            else
            {
                // Знайти реальний залишок для повідомлення про помилку
                var available = await _context.Products
                    .Where(p => p.Id == productId)
                    .Select(p => p.Stock)
                    .FirstOrDefaultAsync();

                failures.Add((productId, available, quantity));
            }
        }

        if (failures.Count > 0)
        {
            // Якщо хоч один товар недоступний → відкотити всі резервування
            await tx.RollbackAsync();

            return ReservationResult.Failed(failures.Select(f =>
                $"Product {f.ProductId}: requested {f.Requested}, available {f.Available}"));
        }

        // Зберегти резервування
        _context.StockReservations.AddRange(reservations);
        await _context.SaveChangesAsync();
        await tx.CommitAsync();

        return ReservationResult.Success(reservations);
    }

    // Звільнення резервування (при скасуванні або закінченні часу)
    public async Task ReleaseReservationAsync(Guid reservationId)
    {
        using var tx = await _context.Database.BeginTransactionAsync();

        var reservation = await _context.StockReservations
            .FirstOrDefaultAsync(r => r.Id == reservationId &&
                                      r.Status == "Active");

        if (reservation is null)
        {
            await tx.RollbackAsync();
            return;
        }

        // Повернути товар на склад
        await _context.Products
            .Where(p => p.Id == reservation.ProductId)
            .ExecuteUpdateAsync(s =>
                s.SetProperty(p => p.Stock, p => p.Stock + reservation.ReservedQty));

        reservation.Status = "Released";
        reservation.ReleasedAt = DateTime.UtcNow;
        await _context.SaveChangesAsync();

        await tx.CommitAsync();
    }
}

Практичні завдання (Частина 1)

Рівень 1 — Базовий

Завдання 1.1: Read Phenomena симуляція

Напишіть три тести що демонструють:

  1. Non-Repeatable Read: два паралельні ReadCommitted запити до одного рядка отримують різні значення (змоделюйте через два DbContext)
  2. Phantom Read: перший SELECT COUNT = 5, після INSERT іншою транзакцією — другий SELECT COUNT = 6
  3. Захист через RepeatableRead: перший сценарій не відтворюється при цьому рівні ізоляції

Завдання 1.2: Check-Then-Act безпека

Для Username в таблиці Users:

  1. Додайте UNIQUE INDEX на Username через міграцію
  2. Реалізуйте RegisterAsync без попередньої перевірки — лише обробка DbUpdateException
  3. Напишіть паралельний тест: 5 задач намагаються зареєструвати одне й те саме ім'я — рівно одна успішна, інші отримують BusinessException

Завдання 1.3: Атомарний UPDATE для інвентаризації

Реалізуйте ReserveStockAsync(int productId, int quantity) через ExecuteUpdateAsync з умовою Stock >= quantity. Напишіть тест з 10 паралельними запитами резервування по 1 штуці з 5 наявних — рівно 5 мають повернути успіх.

Рівень 2 — Логіка

Завдання 2.1: Write Skew Prevention

Система чергувань лікарів: не меньше 1 лікаря у зміні. Відтворіть Write Skew за ReadCommitted:

  1. Doctor A і B обидва бачать 2 лікарів у зміні
  2. Обидва вирішують піти
  3. Обидва успішно зберігають → 0 лікарів (порушення!)

Вирішіть через:

  • Serializable isolation level
  • Або перевірку у тригері/check constraint

Завдання 2.2: Idempotency Key для order creation

Реалізуйте IdempotencyKey таблицю і CreateOrderAsync що приймає Guid idempotencyKey. При повторному виклику з тим самим ключем — повертає існуючий Order (без дублікату). При новому ключі — створює новий. Напишіть тест що симулює network retry.

Рівень 3 — Архітектура

Завдання 3.1: Distributed Lock для критичних секцій

EF Core + SQL Server: реалізуйте IDistributedLock через sp_getapplock (SQL Server Application Lock):

public async Task<bool> TryAcquireAsync(string resource, int timeoutMs);
public async Task ReleaseAsync(string resource);
-- sp_getapplock: іменоване блокування на рівні БД
EXEC sp_getapplock @Resource='order_123', @LockMode='Exclusive', @LockTimeout=5000

Використайте для захисту ReserveStockAsync на рівні конкретного продукту (не всієї таблиці).


Підсумок частини 1

Перша частина розкрила теоретичну і практичну основу конкурентності:

  • Read Phenomena: Dirty, Non-Repeatable, Phantom Read — кожен з прикладом реального бізнес-наслідку. Write Skew як найскладніша аномалія що порушує інваріанти без «видимих» помилок.
  • MVCC vs Lock-based: PostgreSQL зберігає версії рядків — читачі не блокуються. SQL Server використовує lock за замовчуванням (RCSI — альтернатива).
  • Check-Then-Act Race Condition: Unique Index + DbUpdateException — єдиний надійний захист.
  • Double Spending: Атомарний ExecuteUpdateAsync з умовою або Optimistic Concurrency retry loop.
  • Duplicate Insert: Idempotency Key — архітектурне рішення для мережевих retry-ів.

У другій частині — Lock Escalation і дедлоки, детектування та розрив дедлоків, SELECT FOR UPDATE SKIP LOCKED для queue processing, та monitoring concurrency проблем у production.