Skip to main content

Optimistic concurrency: rolled out across every eligible Mas* repository

Every Mas* write used to be last-write-wins — no rowversion, no conflict detection at all. Rolled out in stages between 2026-08-18 and 2026-08-25; this page is the current mechanism and scope. Full build history and the bugs found along the way live in DEVLOG.md entry #19.

:::note Why not a real DB rowversion column everywhere No table in the legacy schema has a real SQL Server rowversion/timestamp column, and StoredProcedure-strategy writes go through a legacy Man* SP that executes unconditionally — there's no way to make the SP itself reject a stale write. That confines the atomic mechanism below to EntityFrameworkCore-strategy repositories; StoredProcedure/Hybrid ones keep an older check-then-act mechanism instead. :::

Two mechanisms, chosen by repository write strategy

StoredProcedure/Hybrid-strategy repositories — an application-layer compare-then-write check. The DTO carries UpdatedDt ([AuditIgnore], concurrency housekeeping, not business data), populated on every read; the caller submits it back unchanged on Update. UpdateAsync re-reads the row and compares before writing — a mismatch returns ServiceResult.Conflict. This is a check-then-act race, not atomic — a narrow TOCTOU window remains, inherent to not being able to change the legacy SP. Still strictly better than no check.

EntityFrameworkCore-strategy repositories — a single atomic conditional UPDATE/DELETE via EF Core's own concurrency-token feature, closing that TOCTOU window entirely, for free. UpdatedDt is mapped IsConcurrencyToken() (an ordinary datetime column works fine as a token — it doesn't need to be a real rowversion). When the caller supplies a stamp, UpdateAsync/DeleteAsync overrides the tracked entity's OriginalValue for that property with the caller's claimed value — EF folds that into the generated UPDATE/DELETE's WHERE clause and throws DbUpdateConcurrencyException when the affected-row count is 0, caught and mapped to ServiceResult.Conflict (clearing the change tracker first so the failed attempt doesn't poison a later call in the same scope).

:::tip A second write shape needs a different variant Several repositories write via context.Set<T>().Where(...).ExecuteUpdateAsync(...) /ExecuteDeleteAsync(...) — bulk, set-based SQL with no tracked EntityEntry<T>. EF's concurrency-token machinery doesn't apply to these write paths at all. The fit instead: fold && x.UpdatedDt == expectedUpdatedDt (only when a stamp was supplied) into the Where(...), and branch on the returned affected-row count — 0 rows means Conflict once an existence pre-check has ruled out NotFound. Used by SysConfigRepository, EmailAccountRepository, TrCodeRepository, and a couple of Delete-only call sites. :::

Signature change: Task<bool>ServiceResult

Application has zero EF Core references (an enforced architecture-test rule), so the OriginalValue/predicate mechanism can only ever live in the Repository. That forced every entity's UpdateAsync/DeleteAsync in this rollout onto ServiceResult instead of bool, wider than the original task's framing — territory-gated entities' <Entity>Service layer now just forwards the repository's ServiceResult, merged with its own Forbidden/territory-check outcome, rather than doing its own redundant compare.

Omitting the stamp on a request skips the check entirely (backward compatible). DeleteAsync carries the same optional check, surfaced on the HTTP route as a query parameter, not a body.

:::caution UpdatedDt reliability must be verified per entity Never assume a Mas* table's UpdatedDt is actually maintained just because the column exists — several legacy tables carry timestamp-shaped columns nobody writes to. Confirm via the real Man* SP body or the repository's own explicit DateTime.UtcNow assignment. Also: a freshly-created row's UpdatedDt is often NULL — its very first edit has no baseline to compare against, so the check is silently skipped by design. :::

Real rowversion columns for brand-new tables

A genuinely new, Eos-owned table (no legacy manage SP in the way) can get a real SQL Server ROWVERSION column instead of mapping an ordinary datetime. MAS_SCENARIOS_V2 is the one example so far: RowVersion (byte[]) mapped .IsRowVersion(), same OriginalValue-override pattern, just against a real rowversion. ScenarioRepository.UpdateAsync returns a dedicated ScenarioUpdateResult enum rather than ServiceResult, following this codebase's "one dedicated result type per operation" convention.

Scope

Covers 20 Mas*/security entities (City, Choice, Country, SysConfig, AppSetting, EmailAccount, Branch, Company, Client, Warehouse, Department, Equipment, ForumType, Queue, TrCode, User, Address, Role, UserGroup, Menu), plus MAS_SCENARIOS_V2 on the real-rowversion path. TpAuthConfig is the one deliberate holdout — it's the last repository still Hybrid-strategy, so it keeps the older check-then-act mechanism. Deliberately excluded: every transactional/Operations-module repository (MasterOrder, WarehouseOrder, their child grids, Attachment, EdiFile) and the automation engine's other EOS_*/_V2 tables (insert-only or startup-sync-only, no real lost-update race to protect against).

:::note Test coverage gap CityRepositoryConcurrencyContainerTests (real SQL Server via Testcontainers) remains the only Docker-gated proof of the mechanism against a real repository/entity — the rollout to the other 19 entities relied on the existing test suite staying green plus manual verification, not a container test per entity. OptimisticConcurrencyMechanismTests (Application.Tests/Mas/) covers the mechanism itself against a real Sqlite in-memory database (not EF's InMemory provider, which doesn't translate ExecuteUpdate/ExecuteDelete), but that's coverage of the mechanism, not a substitute for per-entity container tests. :::

Reference files

CityDto.cs/CityRepository.cs/ICityRepository.cs (the reference example), ServiceResult.cs/ServiceResultExtensions.cs, OptimisticConcurrencyMechanismTests.cs. For the real-rowversion shape: EosScenario.cs/EosScenarioConfiguration.cs, ScenarioRepository.cs, ScenarioUpdateResult.cs.