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>
115 lines
5.6 KiB
C#
115 lines
5.6 KiB
C#
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);
|
||
}
|
||
}
|