From ec0cff7fc4e733dcdb35e285648f5ca5fa8f5e15 Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 28 May 2026 17:33:01 +0500 Subject: [PATCH] =?UTF-8?q?feat(concurrency):=20RowVersion=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=85?= =?UTF-8?q?=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Postgres=20xmin=20(TD-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(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 --- .../Purchases/SuppliesController.cs | 58 +++++++-- .../Common/IVersionedEntity.cs | 16 +++ .../Inventory/InventoryDoc.cs | 4 +- src/food-market.domain/Inventory/Transfer.cs | 4 +- src/food-market.domain/Purchases/Supply.cs | 5 +- src/food-market.domain/Sales/Demand.cs | 4 +- src/food-market.domain/Sales/RetailSale.cs | 4 +- .../Configurations/InventoryConfigurations.cs | 4 + .../Configurations/PurchasesConfigurations.cs | 8 ++ .../Configurations/SalesConfigurations.cs | 4 + .../ConcurrencyTokenTests.cs | 114 ++++++++++++++++++ 11 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 src/food-market.domain/Common/IVersionedEntity.cs create mode 100644 tests/food-market.IntegrationTests/ConcurrencyTokenTests.cs diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index 352ad50..ac39ba1 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -48,6 +48,7 @@ public record SupplyDto( Guid CurrencyId, string CurrencyCode, string? Notes, decimal Total, DateTime? PostedAt, + uint Xmin, IReadOnlyList Lines); public record SupplyLineInput( @@ -59,7 +60,11 @@ public record SupplyLineInput( public record SupplyInput( DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId, string? Notes, - IReadOnlyList Lines); + IReadOnlyList Lines, + // Optimistic concurrency token. null/0 для нового черновика (POST), + // обязателен для PUT — иначе считаем что клиент не передал version + // и пускаем без сверки (legacy). При несовпадении контроллер возвращает 409. + uint? Xmin = null); [HttpGet] public async Task>> List( @@ -180,6 +185,16 @@ public async Task> Create([FromBody] SupplyInput input, await _db.SaveChangesAsync(ct); 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") { // pg.ConstraintName выглядит как FK_supplies_counterparties_SupplierId @@ -209,21 +224,44 @@ public async Task Update(Guid id, [FromBody] SupplyInput input, C if (supply.Status != SupplyStatus.Draft) 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.SupplierId = input.SupplierId; supply.StoreId = input.StoreId; supply.CurrencyId = input.CurrencyId; supply.Notes = input.Notes; - // Replace lines wholesale (simple, idempotent). - _db.SupplyLines.RemoveRange(supply.Lines); - supply.Lines.Clear(); + // Удаляем старые строки через ExecuteDelete (минует трекер), новые + // добавляем напрямую в DbSet — иначе EF8 на nav-collection+client-side Id + // путается и 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 order = 0; foreach (var l in input.Lines) { 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, ProductId = l.ProductId, @@ -237,7 +275,10 @@ public async Task Update(Guid id, [FromBody] SupplyInput input, C : 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; return NoContent(); @@ -475,12 +516,14 @@ private async Task GenerateNumberAsync(DateTime date, CancellationToken private async Task GetInternal(Guid id, CancellationToken ct) { + // Shadow-property xmin читаем через EF.Property — она не на entity, + // а в model metadata (UseXminAsConcurrencyToken). var row = await (from s in _db.Supplies.AsNoTracking() join cp in _db.Counterparties on s.SupplierId equals cp.Id join st in _db.Stores on s.StoreId equals st.Id join cu in _db.Currencies on s.CurrencyId equals cu.Id where s.Id == id - select new { s, cp, st, cu }).FirstOrDefaultAsync(ct); + select new { s, cp, st, cu, Xmin = EF.Property(s, "xmin") }).FirstOrDefaultAsync(ct); if (row is null) return null; // CurrentRetailPrice — текущая розничная цена товара (дефолтный PriceType), @@ -513,6 +556,7 @@ orderby l.SortOrder row.cu.Id, row.cu.Code, row.s.Notes, row.s.Total, row.s.PostedAt, + row.Xmin, lines); } } diff --git a/src/food-market.domain/Common/IVersionedEntity.cs b/src/food-market.domain/Common/IVersionedEntity.cs new file mode 100644 index 0000000..725efba --- /dev/null +++ b/src/food-market.domain/Common/IVersionedEntity.cs @@ -0,0 +1,16 @@ +namespace foodmarket.Domain.Common; + +/// Маркер «сущность с optimistic concurrency token». Postgres-маппинг — +/// системная колонка xmin (тип xid), которая инкрементится +/// автоматически при UPDATE; EF читает текущее значение в SELECT и +/// сверяет в WHERE при следующем UPDATE — конкурирующий апдейт упадёт с +/// 0 affected rows и контроллер вернёт 409 Conflict. +/// +/// Не требует миграции данных (xmin есть на каждой таблице), требует только +/// `e.UseXminAsConcurrencyToken()` в EF-конфигурации + объявление свойства +/// на entity. PUT-эндпоинты должны принимать xmin в body и +/// присваивать в trackedEntity до SaveChanges. +public interface IVersionedEntity +{ + uint Xmin { get; set; } +} diff --git a/src/food-market.domain/Inventory/InventoryDoc.cs b/src/food-market.domain/Inventory/InventoryDoc.cs index 674f7bd..8eacb87 100644 --- a/src/food-market.domain/Inventory/InventoryDoc.cs +++ b/src/food-market.domain/Inventory/InventoryDoc.cs @@ -19,8 +19,10 @@ public enum InventoryStatus /// Назван InventoryDoc чтобы не конфликтовать с .NET-неймспейсом /// System.Collections.Specialized.Inventory и не путаться с самой /// сущностью «остатков». Таблица — inventories. -public class InventoryDoc : TenantEntity +public class InventoryDoc : TenantEntity, IVersionedEntity { + public uint Xmin { get; set; } + public string Number { get; set; } = ""; public DateTime Date { get; set; } = DateTime.UtcNow; public InventoryStatus Status { get; set; } = InventoryStatus.Draft; diff --git a/src/food-market.domain/Inventory/Transfer.cs b/src/food-market.domain/Inventory/Transfer.cs index 48cdda4..88fa323 100644 --- a/src/food-market.domain/Inventory/Transfer.cs +++ b/src/food-market.domain/Inventory/Transfer.cs @@ -14,8 +14,10 @@ public enum TransferStatus /// TransferIn в ToStore (атомарной транзакцией). Unpost тоже атомарен — /// возвращает обратно ОБА движения. Никогда не должно остаться orphan-движений /// (только один из двух). -public class Transfer : TenantEntity +public class Transfer : TenantEntity, IVersionedEntity { + public uint Xmin { get; set; } + public string Number { get; set; } = ""; public DateTime Date { get; set; } = DateTime.UtcNow; public TransferStatus Status { get; set; } = TransferStatus.Draft; diff --git a/src/food-market.domain/Purchases/Supply.cs b/src/food-market.domain/Purchases/Supply.cs index aee4842..5cea398 100644 --- a/src/food-market.domain/Purchases/Supply.cs +++ b/src/food-market.domain/Purchases/Supply.cs @@ -9,8 +9,11 @@ public enum SupplyStatus Posted = 1, } -public class Supply : TenantEntity +public class Supply : TenantEntity, IVersionedEntity { + /// Postgres `xmin` — optimistic concurrency token. См. IVersionedEntity. + public uint Xmin { get; set; } + /// Human-readable document number, unique per organization (e.g. "П-2026-000001"). public string Number { get; set; } = ""; diff --git a/src/food-market.domain/Sales/Demand.cs b/src/food-market.domain/Sales/Demand.cs index 944333a..16de140 100644 --- a/src/food-market.domain/Sales/Demand.cs +++ b/src/food-market.domain/Sales/Demand.cs @@ -15,8 +15,10 @@ public enum DemandStatus /// отличаться от розничных (тип цен «Опт.»). При проведении создаёт /// тип /// с -Quantity. -public class Demand : TenantEntity +public class Demand : TenantEntity, IVersionedEntity { + public uint Xmin { get; set; } + public string Number { get; set; } = ""; public DateTime Date { get; set; } = DateTime.UtcNow; public DemandStatus Status { get; set; } = DemandStatus.Draft; diff --git a/src/food-market.domain/Sales/RetailSale.cs b/src/food-market.domain/Sales/RetailSale.cs index f096b39..37deb7e 100644 --- a/src/food-market.domain/Sales/RetailSale.cs +++ b/src/food-market.domain/Sales/RetailSale.cs @@ -18,8 +18,10 @@ public enum PaymentMethod Mixed = 99, } -public class RetailSale : TenantEntity +public class RetailSale : TenantEntity, IVersionedEntity { + public uint Xmin { get; set; } + public string Number { get; set; } = ""; public DateTime Date { get; set; } = DateTime.UtcNow; public RetailSaleStatus Status { get; set; } = RetailSaleStatus.Draft; diff --git a/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs index f6c1315..fa05e74 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs @@ -71,6 +71,8 @@ public static void ConfigureInventory(this ModelBuilder b) b.Entity(e => { e.ToTable("transfers"); + e.UseXminAsConcurrencyToken(); + e.Ignore(x => x.Xmin); e.Property(x => x.Number).HasMaxLength(50).IsRequired(); e.Property(x => x.Notes).HasMaxLength(1000); e.Property(x => x.Total).HasPrecision(18, 4); @@ -100,6 +102,8 @@ public static void ConfigureInventory(this ModelBuilder b) b.Entity(e => { e.ToTable("inventories"); + e.UseXminAsConcurrencyToken(); + e.Ignore(x => x.Xmin); e.Property(x => x.Number).HasMaxLength(50).IsRequired(); e.Property(x => x.Notes).HasMaxLength(1000); diff --git a/src/food-market.infrastructure/Persistence/Configurations/PurchasesConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/PurchasesConfigurations.cs index 89c4ff3..78308f6 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/PurchasesConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/PurchasesConfigurations.cs @@ -10,6 +10,14 @@ public static void ConfigurePurchases(this ModelBuilder b) b.Entity(e => { e.ToTable("supplies"); + // Optimistic concurrency через Postgres xmin (Npgsql-native): + // UseXminAsConcurrencyToken создаёт shadow-property "xmin" — EF + // её читает в SELECT и сверяет в WHERE при UPDATE. Доступ из C# + // через EF.Property(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.Notes).HasMaxLength(1000); e.Property(x => x.Total).HasPrecision(18, 4); diff --git a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs index c52a582..ab7a3a7 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs @@ -10,6 +10,8 @@ public static void ConfigureSales(this ModelBuilder b) b.Entity(e => { e.ToTable("retail_sales"); + e.UseXminAsConcurrencyToken(); + e.Ignore(x => x.Xmin); e.Property(x => x.Number).HasMaxLength(50).IsRequired(); e.Property(x => x.Notes).HasMaxLength(1000); e.Property(x => x.Subtotal).HasPrecision(18, 4); @@ -63,6 +65,8 @@ public static void ConfigureSales(this ModelBuilder b) b.Entity(e => { e.ToTable("demands"); + e.UseXminAsConcurrencyToken(); + e.Ignore(x => x.Xmin); e.Property(x => x.Number).HasMaxLength(50).IsRequired(); e.Property(x => x.Notes).HasMaxLength(1000); e.Property(x => x.Subtotal).HasPrecision(18, 4); diff --git a/tests/food-market.IntegrationTests/ConcurrencyTokenTests.cs b/tests/food-market.IntegrationTests/ConcurrencyTokenTests.cs new file mode 100644 index 0000000..3394815 --- /dev/null +++ b/tests/food-market.IntegrationTests/ConcurrencyTokenTests.cs @@ -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))); + + /// Два параллельных PUT с одинаковым xmin: первый успешен (204), + /// второй падает 409 (concurrency_conflict). После 409 клиент должен + /// перезагрузить документ и попробовать снова — это тестируем третим PUT. + [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(); + 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); + } + + /// Старый клиент без поля xmin продолжает работать — Update без + /// concurrency-проверки (legacy compatibility). + [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()).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); + } +}