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.