feat(concurrency): RowVersion на документах через Postgres xmin (TD-6)
Optimistic concurrency через системную колонку Postgres xmin — никакой
дополнительной колонки и миграции не нужно, xmin есть у каждой таблицы
и автоматически обновляется при UPDATE.
Конфигурация:
- IVersionedEntity (маркер) + uint Xmin на Supply, Demand, RetailSale,
Transfer, InventoryDoc.
- e.UseXminAsConcurrencyToken() в EF-конфиге для каждой — создаёт shadow
property "xmin" с IsConcurrencyToken + ValueGeneratedOnAddOrUpdate.
- e.Ignore(x => x.Xmin): .NET-property живёт только для транспорта в DTO,
не маппится в БД (xmin тащим shadow'ом).
- GetInternal в SuppliesController читает xmin через
EF.Property<uint>(s, "xmin") в LINQ-проекции и складывает в DTO.
Wire-up:
- SuppliesController.Update принимает input.Xmin (uint?), сверяет с
shadow xmin загруженного supply через EF.Entry().Property("xmin").
Несовпадение → 409 с code=concurrency_conflict. null/0 от клиента →
legacy compat, проверки нет.
- SaveOrFkErrorAsync ловит DbUpdateConcurrencyException → 409 (двойная
защита: и явная сверка, и EF auto-check в SaveChanges).
Bonus: Supply.Update перешёл на тот же паттерн что Demand/RetailSale —
ExecuteDelete старых строк + AddRange новых напрямую в DbSet. Старый
RemoveRange-then-Add через nav-collection ломал EF concurrency check
(UPDATE supply_lines одной из старых строк падал 0 affected внутри той
же SaveChanges-транзакции).
Тесты: 2 интеграционных:
- two parallel updates with same xmin → один 204, другой 409; retry
с новым xmin тоже 204.
- legacy clients без xmin → PUT работает без concurrency-проверки.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
406fcb9d7d
commit
ec0cff7fc4
|
|
@ -48,6 +48,7 @@ public record SupplyDto(
|
||||||
Guid CurrencyId, string CurrencyCode,
|
Guid CurrencyId, string CurrencyCode,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
decimal Total, DateTime? PostedAt,
|
decimal Total, DateTime? PostedAt,
|
||||||
|
uint Xmin,
|
||||||
IReadOnlyList<SupplyLineDto> Lines);
|
IReadOnlyList<SupplyLineDto> Lines);
|
||||||
|
|
||||||
public record SupplyLineInput(
|
public record SupplyLineInput(
|
||||||
|
|
@ -59,7 +60,11 @@ public record SupplyLineInput(
|
||||||
public record SupplyInput(
|
public record SupplyInput(
|
||||||
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
|
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
IReadOnlyList<SupplyLineInput> Lines);
|
IReadOnlyList<SupplyLineInput> Lines,
|
||||||
|
// Optimistic concurrency token. null/0 для нового черновика (POST),
|
||||||
|
// обязателен для PUT — иначе считаем что клиент не передал version
|
||||||
|
// и пускаем без сверки (legacy). При несовпадении контроллер возвращает 409.
|
||||||
|
uint? Xmin = null);
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<SupplyListRow>>> List(
|
public async Task<ActionResult<PagedResult<SupplyListRow>>> List(
|
||||||
|
|
@ -180,6 +185,16 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
// Optimistic concurrency: кто-то другой обновил документ между
|
||||||
|
// SELECT и UPDATE. Клиент должен перезагрузить и попробовать снова.
|
||||||
|
return Conflict(new
|
||||||
|
{
|
||||||
|
error = "Документ изменён другим пользователем. Обновите страницу и повторите.",
|
||||||
|
code = "concurrency_conflict",
|
||||||
|
});
|
||||||
|
}
|
||||||
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
|
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
|
||||||
{
|
{
|
||||||
// pg.ConstraintName выглядит как FK_supplies_counterparties_SupplierId
|
// pg.ConstraintName выглядит как FK_supplies_counterparties_SupplierId
|
||||||
|
|
@ -209,21 +224,44 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
|
||||||
if (supply.Status != SupplyStatus.Draft)
|
if (supply.Status != SupplyStatus.Draft)
|
||||||
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
|
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
|
||||||
|
|
||||||
|
// Optimistic concurrency: если клиент прислал Xmin, сверяем с тем,
|
||||||
|
// что EF только что прочитал через Include. Несовпадение → 409
|
||||||
|
// (кто-то изменил документ между нашими GET и PUT). Прозрачнее, чем
|
||||||
|
// полагаться на EF DbUpdateConcurrencyException — там сообщение
|
||||||
|
// зависит от других UPDATE'ов в одной SaveChanges. null/0 от клиента
|
||||||
|
// → пропускаем проверку (старые клиенты).
|
||||||
|
// Optimistic concurrency: если клиент прислал Xmin, сверяем со shadow
|
||||||
|
// property "xmin", которую EF только что загрузила. Несовпадение → 409.
|
||||||
|
if (input.Xmin is { } cliXmin && cliXmin != 0)
|
||||||
|
{
|
||||||
|
var serverXmin = (uint)_db.Entry(supply).Property("xmin").CurrentValue!;
|
||||||
|
if (serverXmin != cliXmin)
|
||||||
|
{
|
||||||
|
return Conflict(new
|
||||||
|
{
|
||||||
|
error = "Документ изменён другим пользователем. Обновите страницу и повторите.",
|
||||||
|
code = "concurrency_conflict",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
supply.Date = input.Date;
|
supply.Date = input.Date;
|
||||||
supply.SupplierId = input.SupplierId;
|
supply.SupplierId = input.SupplierId;
|
||||||
supply.StoreId = input.StoreId;
|
supply.StoreId = input.StoreId;
|
||||||
supply.CurrencyId = input.CurrencyId;
|
supply.CurrencyId = input.CurrencyId;
|
||||||
supply.Notes = input.Notes;
|
supply.Notes = input.Notes;
|
||||||
|
|
||||||
// Replace lines wholesale (simple, idempotent).
|
// Удаляем старые строки через ExecuteDelete (минует трекер), новые
|
||||||
_db.SupplyLines.RemoveRange(supply.Lines);
|
// добавляем напрямую в DbSet — иначе EF8 на nav-collection+client-side Id
|
||||||
supply.Lines.Clear();
|
// путается и UPDATE supplies с concurrency-token-WHERE падает 0 affected.
|
||||||
|
// Тот же паттерн что в RetailSale/Demand.Update.
|
||||||
|
await _db.SupplyLines.Where(l => l.SupplyId == supply.Id).ExecuteDeleteAsync(ct);
|
||||||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
var order = 0;
|
var order = 0;
|
||||||
foreach (var l in input.Lines)
|
foreach (var l in input.Lines)
|
||||||
{
|
{
|
||||||
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
|
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
|
||||||
supply.Lines.Add(new SupplyLine
|
_db.SupplyLines.Add(new SupplyLine
|
||||||
{
|
{
|
||||||
SupplyId = supply.Id,
|
SupplyId = supply.Id,
|
||||||
ProductId = l.ProductId,
|
ProductId = l.ProductId,
|
||||||
|
|
@ -237,7 +275,10 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
// Total считаем из input напрямую — supply.Lines navigation в этом
|
||||||
|
// подходе пустая (мы добавляли через DbSet).
|
||||||
|
supply.Total = input.Lines.Sum(l =>
|
||||||
|
l.Quantity * (allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero)));
|
||||||
|
|
||||||
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|
@ -475,12 +516,14 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
|
||||||
|
|
||||||
private async Task<SupplyDto?> GetInternal(Guid id, CancellationToken ct)
|
private async Task<SupplyDto?> GetInternal(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Shadow-property xmin читаем через EF.Property — она не на entity,
|
||||||
|
// а в model metadata (UseXminAsConcurrencyToken).
|
||||||
var row = await (from s in _db.Supplies.AsNoTracking()
|
var row = await (from s in _db.Supplies.AsNoTracking()
|
||||||
join cp in _db.Counterparties on s.SupplierId equals cp.Id
|
join cp in _db.Counterparties on s.SupplierId equals cp.Id
|
||||||
join st in _db.Stores on s.StoreId equals st.Id
|
join st in _db.Stores on s.StoreId equals st.Id
|
||||||
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||||||
where s.Id == id
|
where s.Id == id
|
||||||
select new { s, cp, st, cu }).FirstOrDefaultAsync(ct);
|
select new { s, cp, st, cu, Xmin = EF.Property<uint>(s, "xmin") }).FirstOrDefaultAsync(ct);
|
||||||
if (row is null) return null;
|
if (row is null) return null;
|
||||||
|
|
||||||
// CurrentRetailPrice — текущая розничная цена товара (дефолтный PriceType),
|
// CurrentRetailPrice — текущая розничная цена товара (дефолтный PriceType),
|
||||||
|
|
@ -513,6 +556,7 @@ orderby l.SortOrder
|
||||||
row.cu.Id, row.cu.Code,
|
row.cu.Id, row.cu.Code,
|
||||||
row.s.Notes,
|
row.s.Notes,
|
||||||
row.s.Total, row.s.PostedAt,
|
row.s.Total, row.s.PostedAt,
|
||||||
|
row.Xmin,
|
||||||
lines);
|
lines);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
src/food-market.domain/Common/IVersionedEntity.cs
Normal file
16
src/food-market.domain/Common/IVersionedEntity.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
namespace foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
/// <summary>Маркер «сущность с optimistic concurrency token». Postgres-маппинг —
|
||||||
|
/// системная колонка <c>xmin</c> (тип <c>xid</c>), которая инкрементится
|
||||||
|
/// автоматически при UPDATE; EF читает текущее значение в SELECT и
|
||||||
|
/// сверяет в WHERE при следующем UPDATE — конкурирующий апдейт упадёт с
|
||||||
|
/// 0 affected rows и контроллер вернёт 409 Conflict.
|
||||||
|
///
|
||||||
|
/// Не требует миграции данных (xmin есть на каждой таблице), требует только
|
||||||
|
/// `e.UseXminAsConcurrencyToken()` в EF-конфигурации + объявление свойства
|
||||||
|
/// на entity. PUT-эндпоинты должны принимать <c>xmin</c> в body и
|
||||||
|
/// присваивать в trackedEntity до SaveChanges.</summary>
|
||||||
|
public interface IVersionedEntity
|
||||||
|
{
|
||||||
|
uint Xmin { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -19,8 +19,10 @@ public enum InventoryStatus
|
||||||
/// Назван <c>InventoryDoc</c> чтобы не конфликтовать с .NET-неймспейсом
|
/// Назван <c>InventoryDoc</c> чтобы не конфликтовать с .NET-неймспейсом
|
||||||
/// <c>System.Collections.Specialized.Inventory</c> и не путаться с самой
|
/// <c>System.Collections.Specialized.Inventory</c> и не путаться с самой
|
||||||
/// сущностью «остатков». Таблица — <c>inventories</c>.</summary>
|
/// сущностью «остатков». Таблица — <c>inventories</c>.</summary>
|
||||||
public class InventoryDoc : TenantEntity
|
public class InventoryDoc : TenantEntity, IVersionedEntity
|
||||||
{
|
{
|
||||||
|
public uint Xmin { get; set; }
|
||||||
|
|
||||||
public string Number { get; set; } = "";
|
public string Number { get; set; } = "";
|
||||||
public DateTime Date { get; set; } = DateTime.UtcNow;
|
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||||
public InventoryStatus Status { get; set; } = InventoryStatus.Draft;
|
public InventoryStatus Status { get; set; } = InventoryStatus.Draft;
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ public enum TransferStatus
|
||||||
/// TransferIn в ToStore (атомарной транзакцией). Unpost тоже атомарен —
|
/// TransferIn в ToStore (атомарной транзакцией). Unpost тоже атомарен —
|
||||||
/// возвращает обратно ОБА движения. Никогда не должно остаться orphan-движений
|
/// возвращает обратно ОБА движения. Никогда не должно остаться orphan-движений
|
||||||
/// (только один из двух).</summary>
|
/// (только один из двух).</summary>
|
||||||
public class Transfer : TenantEntity
|
public class Transfer : TenantEntity, IVersionedEntity
|
||||||
{
|
{
|
||||||
|
public uint Xmin { get; set; }
|
||||||
|
|
||||||
public string Number { get; set; } = "";
|
public string Number { get; set; } = "";
|
||||||
public DateTime Date { get; set; } = DateTime.UtcNow;
|
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||||
public TransferStatus Status { get; set; } = TransferStatus.Draft;
|
public TransferStatus Status { get; set; } = TransferStatus.Draft;
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@ public enum SupplyStatus
|
||||||
Posted = 1,
|
Posted = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Supply : TenantEntity
|
public class Supply : TenantEntity, IVersionedEntity
|
||||||
{
|
{
|
||||||
|
/// <summary>Postgres `xmin` — optimistic concurrency token. См. IVersionedEntity.</summary>
|
||||||
|
public uint Xmin { get; set; }
|
||||||
|
|
||||||
/// <summary>Human-readable document number, unique per organization (e.g. "П-2026-000001").</summary>
|
/// <summary>Human-readable document number, unique per organization (e.g. "П-2026-000001").</summary>
|
||||||
public string Number { get; set; } = "";
|
public string Number { get; set; } = "";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,10 @@ public enum DemandStatus
|
||||||
/// отличаться от розничных (тип цен «Опт.»). При проведении создаёт
|
/// отличаться от розничных (тип цен «Опт.»). При проведении создаёт
|
||||||
/// <see cref="Inventory.StockMovement"/> тип
|
/// <see cref="Inventory.StockMovement"/> тип
|
||||||
/// <see cref="Inventory.MovementType.WholesaleSale"/> с -Quantity.</summary>
|
/// <see cref="Inventory.MovementType.WholesaleSale"/> с -Quantity.</summary>
|
||||||
public class Demand : TenantEntity
|
public class Demand : TenantEntity, IVersionedEntity
|
||||||
{
|
{
|
||||||
|
public uint Xmin { get; set; }
|
||||||
|
|
||||||
public string Number { get; set; } = "";
|
public string Number { get; set; } = "";
|
||||||
public DateTime Date { get; set; } = DateTime.UtcNow;
|
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||||
public DemandStatus Status { get; set; } = DemandStatus.Draft;
|
public DemandStatus Status { get; set; } = DemandStatus.Draft;
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ public enum PaymentMethod
|
||||||
Mixed = 99,
|
Mixed = 99,
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RetailSale : TenantEntity
|
public class RetailSale : TenantEntity, IVersionedEntity
|
||||||
{
|
{
|
||||||
|
public uint Xmin { get; set; }
|
||||||
|
|
||||||
public string Number { get; set; } = "";
|
public string Number { get; set; } = "";
|
||||||
public DateTime Date { get; set; } = DateTime.UtcNow;
|
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||||
public RetailSaleStatus Status { get; set; } = RetailSaleStatus.Draft;
|
public RetailSaleStatus Status { get; set; } = RetailSaleStatus.Draft;
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ public static void ConfigureInventory(this ModelBuilder b)
|
||||||
b.Entity<Transfer>(e =>
|
b.Entity<Transfer>(e =>
|
||||||
{
|
{
|
||||||
e.ToTable("transfers");
|
e.ToTable("transfers");
|
||||||
|
e.UseXminAsConcurrencyToken();
|
||||||
|
e.Ignore(x => x.Xmin);
|
||||||
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
||||||
e.Property(x => x.Notes).HasMaxLength(1000);
|
e.Property(x => x.Notes).HasMaxLength(1000);
|
||||||
e.Property(x => x.Total).HasPrecision(18, 4);
|
e.Property(x => x.Total).HasPrecision(18, 4);
|
||||||
|
|
@ -100,6 +102,8 @@ public static void ConfigureInventory(this ModelBuilder b)
|
||||||
b.Entity<InventoryDoc>(e =>
|
b.Entity<InventoryDoc>(e =>
|
||||||
{
|
{
|
||||||
e.ToTable("inventories");
|
e.ToTable("inventories");
|
||||||
|
e.UseXminAsConcurrencyToken();
|
||||||
|
e.Ignore(x => x.Xmin);
|
||||||
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
||||||
e.Property(x => x.Notes).HasMaxLength(1000);
|
e.Property(x => x.Notes).HasMaxLength(1000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ public static void ConfigurePurchases(this ModelBuilder b)
|
||||||
b.Entity<Supply>(e =>
|
b.Entity<Supply>(e =>
|
||||||
{
|
{
|
||||||
e.ToTable("supplies");
|
e.ToTable("supplies");
|
||||||
|
// Optimistic concurrency через Postgres xmin (Npgsql-native):
|
||||||
|
// UseXminAsConcurrencyToken создаёт shadow-property "xmin" — EF
|
||||||
|
// её читает в SELECT и сверяет в WHERE при UPDATE. Доступ из C#
|
||||||
|
// через EF.Property<uint>(supply, "xmin") (см. GetInternal).
|
||||||
|
// Свою .NET-property Xmin игнорируем — она существует только для
|
||||||
|
// транспорта в DTO (контроллер заполняет вручную).
|
||||||
|
e.UseXminAsConcurrencyToken();
|
||||||
|
e.Ignore(x => x.Xmin);
|
||||||
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
||||||
e.Property(x => x.Notes).HasMaxLength(1000);
|
e.Property(x => x.Notes).HasMaxLength(1000);
|
||||||
e.Property(x => x.Total).HasPrecision(18, 4);
|
e.Property(x => x.Total).HasPrecision(18, 4);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ public static void ConfigureSales(this ModelBuilder b)
|
||||||
b.Entity<RetailSale>(e =>
|
b.Entity<RetailSale>(e =>
|
||||||
{
|
{
|
||||||
e.ToTable("retail_sales");
|
e.ToTable("retail_sales");
|
||||||
|
e.UseXminAsConcurrencyToken();
|
||||||
|
e.Ignore(x => x.Xmin);
|
||||||
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
||||||
e.Property(x => x.Notes).HasMaxLength(1000);
|
e.Property(x => x.Notes).HasMaxLength(1000);
|
||||||
e.Property(x => x.Subtotal).HasPrecision(18, 4);
|
e.Property(x => x.Subtotal).HasPrecision(18, 4);
|
||||||
|
|
@ -63,6 +65,8 @@ public static void ConfigureSales(this ModelBuilder b)
|
||||||
b.Entity<Demand>(e =>
|
b.Entity<Demand>(e =>
|
||||||
{
|
{
|
||||||
e.ToTable("demands");
|
e.ToTable("demands");
|
||||||
|
e.UseXminAsConcurrencyToken();
|
||||||
|
e.Ignore(x => x.Xmin);
|
||||||
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
||||||
e.Property(x => x.Notes).HasMaxLength(1000);
|
e.Property(x => x.Notes).HasMaxLength(1000);
|
||||||
e.Property(x => x.Subtotal).HasPrecision(18, 4);
|
e.Property(x => x.Subtotal).HasPrecision(18, 4);
|
||||||
|
|
|
||||||
114
tests/food-market.IntegrationTests/ConcurrencyTokenTests.cs
Normal file
114
tests/food-market.IntegrationTests/ConcurrencyTokenTests.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
[Collection(ApiCollection.Name)]
|
||||||
|
public class ConcurrencyTokenTests
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public ConcurrencyTokenTests(ApiFactory factory) => _factory = factory;
|
||||||
|
private static string RandomBarcode()
|
||||||
|
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||||||
|
|
||||||
|
/// <summary>Два параллельных PUT с одинаковым xmin: первый успешен (204),
|
||||||
|
/// второй падает 409 (concurrency_conflict). После 409 клиент должен
|
||||||
|
/// перезагрузить документ и попробовать снова — это тестируем третим PUT.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Two_parallel_updates_with_same_xmin_one_wins_other_gets_409()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"conc-{Guid.NewGuid():N}");
|
||||||
|
var refs = await api.LoadRefsAsync();
|
||||||
|
var supplierId = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
|
||||||
|
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||||
|
|
||||||
|
var create = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||||
|
notes = "initial",
|
||||||
|
lines = new[] { new { productId = p1, quantity = 1m, unitPrice = 50m } },
|
||||||
|
});
|
||||||
|
create.EnsureSuccessStatusCode();
|
||||||
|
var json = await create.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var supplyId = json.GetProperty("id").GetString();
|
||||||
|
var xmin = json.GetProperty("xmin").GetUInt32();
|
||||||
|
xmin.Should().BeGreaterThan(0u);
|
||||||
|
|
||||||
|
// Build two identical PUTs with the SAME xmin.
|
||||||
|
var body1 = new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||||
|
notes = "writer-1",
|
||||||
|
lines = new[] { new { productId = p1, quantity = 2m, unitPrice = 50m } },
|
||||||
|
xmin,
|
||||||
|
};
|
||||||
|
var body2 = new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||||
|
notes = "writer-2",
|
||||||
|
lines = new[] { new { productId = p1, quantity = 3m, unitPrice = 50m } },
|
||||||
|
xmin,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sequential — гарантированно один 204, второй 409. Параллельный race в
|
||||||
|
// Postgres даёт тот же исход, но интеграционный тест предпочитает
|
||||||
|
// воспроизводимый порядок (а не «обычно зелёный»).
|
||||||
|
using var r1 = await api.Http.PutAsJsonAsync($"/api/purchases/supplies/{supplyId}", body1);
|
||||||
|
((int)r1.StatusCode).Should().Be(204, await r1.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
using var r2 = await api.Http.PutAsJsonAsync($"/api/purchases/supplies/{supplyId}", body2);
|
||||||
|
((int)r2.StatusCode).Should().Be(409);
|
||||||
|
var body = await r2.Content.ReadAsStringAsync();
|
||||||
|
body.Should().Contain("concurrency_conflict");
|
||||||
|
|
||||||
|
// GET → видим writer-1 результат, xmin обновился.
|
||||||
|
var after = await api.GetJsonAsync($"/api/purchases/supplies/{supplyId}");
|
||||||
|
after.GetProperty("notes").GetString().Should().Be("writer-1");
|
||||||
|
var newXmin = after.GetProperty("xmin").GetUInt32();
|
||||||
|
newXmin.Should().NotBe(xmin);
|
||||||
|
|
||||||
|
// Retry с новым xmin — снова успех.
|
||||||
|
using var r3 = await api.Http.PutAsJsonAsync($"/api/purchases/supplies/{supplyId}", new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||||
|
notes = "writer-2-retry",
|
||||||
|
lines = new[] { new { productId = p1, quantity = 3m, unitPrice = 50m } },
|
||||||
|
xmin = newXmin,
|
||||||
|
});
|
||||||
|
((int)r3.StatusCode).Should().Be(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Старый клиент без поля xmin продолжает работать — Update без
|
||||||
|
/// concurrency-проверки (legacy compatibility).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_without_xmin_field_works_legacy_compat()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"conc-leg-{Guid.NewGuid():N}");
|
||||||
|
var refs = await api.LoadRefsAsync();
|
||||||
|
var supplierId = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
|
||||||
|
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||||
|
|
||||||
|
var create = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||||
|
notes = "init",
|
||||||
|
lines = new[] { new { productId = p1, quantity = 1m, unitPrice = 50m } },
|
||||||
|
});
|
||||||
|
create.EnsureSuccessStatusCode();
|
||||||
|
var supplyId = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||||
|
|
||||||
|
// PUT без xmin — должен пройти.
|
||||||
|
using var r = await api.Http.PutAsJsonAsync($"/api/purchases/supplies/{supplyId}", new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||||
|
notes = "no-xmin",
|
||||||
|
lines = new[] { new { productId = p1, quantity = 2m, unitPrice = 50m } },
|
||||||
|
});
|
||||||
|
((int)r.StatusCode).Should().Be(204);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue