# ConcurrencyCheck

> Refuses an update whose value was changed by somebody else since this writer read it. Field-scoped — two people editing different fields of one record are not in conflict — and raised as a ConflictException the app can catch.

<!-- id: entity-concurrency-check · area: entity · stability: stable · html: https://osysharp.com/reference/entity/concurrency-check/ -->

## Summary        {#summary}
`[ConcurrencyCheck]` refuses an update whose value was changed by somebody else **since this writer read it**. It is
what stops the last save winning silently — the failure mode where two people open the same record, both save, and
the first one's work disappears with nothing said to either of them.

It is **field-scoped**: only the fields a write actually sets are checked, so two people editing different fields of
the same record both succeed.

## Signature      {#signature}
```osy syntax
[ConcurrencyCheck]              // on the ENTITY — guards every property
entity Case { … }

entity Case {
  [ConcurrencyCheck] decimal? Amount;    // …or on ONE property
}
```

## Description    {#description}

### What it does
A guarded update only lands if the row still holds the value this writer read. If it does not, the write is refused
and nothing is written — the other person's value stands, and yours is still in front of you to re-apply.

The refusal is a [ConflictException](https://osysharp.com/reference/function/throw/), so you catch it exactly as you catch any other conflict —
and then say what you want done with the write that failed:

```osy syntax
try { c.Amount = amount; UnitOfWork.Commit(); }
catch (ConflictException e) { message = e.Message; UnitOfWork.Discard(); }
```

### Why didn't my catch stick? {#abandon-or-repair}
Catching the refusal does not un-stage the write that caused it. The row is still holding your value, and the
platform commits once more when your function ends — so **the same refusal is raised a second time**, from a place
no `try` of yours can reach.

That is not an accident: it is what lets a catch **repair** the value and have the repair committed for you. So the
catch has to say which of the two you meant, and there are only two:

| you want | write this in the catch |
|---|---|
| **abandon** the write — show the message, keep the other person's value | `UnitOfWork.Discard();` |
| **retry** it — resolve the conflict and save your value after all | set the field again; it commits when the function ends |

Leave out both and the run ends with the refusal you already handled. The second one says so, and names these
two. It is the same rule a refused CONSTRAINT follows, worked through in full at
[[function-throw#the-refused-row]].

### Field-scoped, and why that matters
Only the columns a write SETS are checked. If Anna changes `Amount` and Bo adds a note to the same record, neither is
refused — they were never in conflict.

The alternative, refusing on any change to the row, is simpler and worse: it refuses writes that are not conflicts,
and a conflict message that fires when nothing is wrong is one people learn to click past. That is the failure this
scoping exists to avoid.

### It is one statement, so there is no window
The check rides the UPDATE's own `WHERE`:

```sql
SET amount = @amount WHERE id = @id AND amount IS NOT DISTINCT FROM @was
```

Deciding and writing are the same statement, so nothing can change between them. The two designs that look
equivalent both have a window: re-reading the row and comparing leaves a gap before the write, and consulting the
audit trail is worse still — the trail is written *after* the commit, so a competing writer's entry may not be there
yet.

### What "since it was read" means across a page load
The value compared against is what **this writer read**, not what the row holds now. That distinction is the whole
feature: a person holding a page across a round trip is the only writer who can be stale, and their belief is pinned
when they read it, so it survives being resumed later.

### The message
The refusal names the fields it refused, and **names who changed the row and when** when there is somebody to name —
that comes from the entity-change audit trail. An anonymous write records no user, so the sentence falls back to
"somebody else". If your app turns the trail off, the refusal still works and the sentence loses the name; a lint
rule (`security-concurrency-check-without-its-trail`) says so at compile time.

### What it does not do
It does not tell you somebody else has the record open — that is presence, a different mechanism. It does not lock
anything: nobody is blocked from editing, and a conflict is only possible when two writes genuinely overlap on one
field.

## Examples       {#examples}

```osy title="guard the whole record" test app=concurrency-check
[ConcurrencyCheck]
entity Case {
  [Required, MaxLength(40)] string Reference;
  decimal? Amount;
  [MaxLength(200)] string? Owner;

  security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
```

```osy title="catch the refusal and abandon the failed write" test app=concurrency-check
string Save(Guid id, decimal amount) {
  var c = Case.Single(x => x.Id == id);
  try {
    c.Amount = amount;
    UnitOfWork.Commit();
    return "saved";
  } catch (ConflictException e) {
    UnitOfWork.Discard();          // …or set c.Amount again, to save yours after all
    return e.Message;
  }
}
```

## How do I test it? {#testing}

A conflict needs **two writers**, and a test body is one — so the thing to stage is not two readers, it is a
**settle in between**. Everything one function call stages is a single unit of work however many times it writes,
so the other person's change has to reach the database before yours is attempted:

1. read the row into a variable — this is what *you* believe it says;
2. make the other person's change and `UnitOfWork.Commit()` it;
3. write through the variable from step 1 and commit.

Step 2's commit is the whole trick. Without it there is only one writer and nothing is refused.

⚠ **Two things that look like they matter and do not.** Reading the row twice does not help — both reads give you
the same tracked row, so it is still one writer. Neither does `runas`, in either spelling: a conflict is about
*when* a write settled, never about *who* settled it, and staging one under two principals stages the same single
writer twice.

```osy title="the model under test" test app=concurrency-conflict
[ConcurrencyCheck]
entity Ledger {
  [Required, MaxLength(40)] string Code;
  decimal? Amount;
  security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}

/// The OTHER person's save.
void MoveAmount(Guid id, decimal amount) {
  var l = Ledger.Single(x => x.Id == id);
  l.Amount = amount;
}
```

```osy title="staging the two writers" run app=concurrency-conflict
[TestFixture]
void Seed() {
  var l = new Ledger { Code = "L-1", Amount = 100 };
}

[Test(Seed)]
void A_stale_write_is_refused_and_the_other_persons_value_stands() {
  var mine = Ledger.Single(x => x.Code == "L-1");   // 1 — what I believe it says
  MoveAmount(mine.Id, 250);
  UnitOfWork.Commit();                               // 2 — …and somebody else's save lands

  string refused = "";
  try { mine.Amount = 999; UnitOfWork.Commit(); }    // 3 — my save, against what I read
  catch (ConflictException e) { refused = e.Message; UnitOfWork.Discard(); }

  Assert.Contains("changed", refused);
  Assert.Equal(250, Ledger.Single(x => x.Code == "L-1").Amount);
}

// …and the same test WITHOUT step 2's commit refuses nothing, which is what makes that line the mechanism
// rather than a detail of this example.
[Test(Seed)]
void With_no_settle_between_them_there_is_only_one_writer() {
  var mine = Ledger.Single(x => x.Code == "L-1");
  MoveAmount(mine.Id, 250);
  string refused = "";
  try { mine.Amount = 999; UnitOfWork.Commit(); }
  catch (ConflictException e) { refused = e.Message; UnitOfWork.Discard(); }
  Assert.Equal("", refused);
}
```

`Assert.Throws<ConflictException>(() => …)` works too, and it does step 2 for you — it settles whatever is already
staged before running its body. That is convenient and it is also why a test can pass under `Assert.Throws` and
prove nothing about the same code written with `try`/`catch`: the assertion supplied the missing commit.

## See also       {#see-also}
- [constraints](https://osysharp.com/reference/entity/constraints/) — the other per-member rules, checked when the row is written
- [throw](https://osysharp.com/reference/function/throw/) — `ConflictException` and the closed set of fault types
- [try / catch / finally](https://osysharp.com/reference/function/try-catch/) — catching it
- [entity](https://osysharp.com/reference/entity/declaration/) — where the attribute goes
