Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne program

Reference / Entity

ConcurrencyCheck

[ConcurrencyCheck] // on an entity (every property) or on one property

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.

stable4 examples compiled by CIentitymodelconcurrencyvalidation

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#

[ConcurrencyCheck]              // on the ENTITY — guards every property
entity Case { … }

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

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, so you catch it exactly as you catch any other conflict — and then say what you want done with the write that failed:

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

Why didn't my catch stick?#

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 wantwrite this in the catch
abandon the write — show the message, keep the other person's valueUnitOfWork.Discard();
retry it — resolve the conflict and save your value after allset 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:

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#

[ConcurrencyCheck]
entity Case {
  [Required, MaxLength(40)] string Reference;
  decimal? Amount;
  [MaxLength(200)] string? Owner;

  security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
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?#

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.

[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;
}
[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#

Related

constraints

The per-member rules the database enforces — Required, Unique, MaxLength/MinLength, Min/Max, Pattern, and the…

throw

Raises a fault. It ends the function immediately, and the rows the function wrote are discarded rather than…

try / catch / finally

Handles a fault. C#'s syntax, including typed catches, catch filters and finally. The rows written inside a try block…

entity

Declares a persisted type — a table of rows the app stores, queries and secures. Every entity gets an Id and audit…

[runas(Name)] test attribute and principal selectors

A test runs deny-all as an anonymous principal, so to read or write real data it must act AS a seeded `[Principal]`…