Osy#betaRun it on Osyrin: osyrin.com

Reference / Entity

ConcurrencyCheck

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

Plain optimistic concurrency: refuses an update whose value was changed by somebody else after the value the write was based on — what the writer read, or what a page last showed. Field-scoped, and raised as a ConflictException.

stable5 examples compiled by CIentitymodelconcurrencyvalidation

Summary#

[ConcurrencyCheck] is plain optimistic concurrency: an update is checked against the value it was based on — what the writer read, or, on a page, what the page last showed — and refused when somebody else changed it after that. It is what stops the last save winning silently — 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 the write was based on. 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 — whoever called the function: a page, a tool, an API call or osy run. osy lint says it before anything runs (data-caught-write-fault-left-staged): a catch (ConflictException …) after a commit that neither discards, rethrows nor writes. It is the same rule a refused CONSTRAINT follows, worked through in full at [[function-throw#the-refused-row]].

How do I take one, so that only one caller gets it?#

Two callers ask for the same free row at the same moment — two agents taking one piece of work, two people booking the last place. Read it, check it is free, set it, and commit inside a try: the guarded update lets exactly one of them land, and the other is refused at its commit. The loser's answer is yours to give — after discarding the write that lost:

[ConcurrencyCheck]
entity Berth {
  [Required, MaxLength(60)] string Name;
  bool Taken;
  [MaxLength(60)] string? HeldBy;
  security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}

[Server, AllowAnonymous]
string Take(Guid id, string who) {
  var b = Berth.Single(x => x.Id == id);
  if (b.Taken) { return "That berth is no longer free."; }
  try {
    b.Taken = true;
    b.HeldBy = who;
    UnitOfWork.Commit();
  } catch (ConflictException e) {
    UnitOfWork.Discard();                                // the other caller's write stands; mine is dropped
    return "Somebody took that berth a moment before you.";
  }
  return "It is yours.";
}

Without the UnitOfWork.Discard() the catch still runs — and then the function's own commit at its end tries the refused write again, so the loser gets the platform's conflict instead of your sentence. No ordinary test shows it (one test body is one writer); it takes two callers at once, which is why the lint looks for the shape.

What is a write based on — the page, or a function it calls?#

A page held open has two ways to write a guarded row, and each is checked against the value it was based on:

the writeis based on
the PAGE's own — editing.Amount = draft; UnitOfWork.Commit(); in an actionthe value the page last SHOWED for that field. A page that reads the row live is told of somebody else's change and shows it; its save is then based on the new value and lands. Only a change that landed after the page's last refresh is a conflict
a function the page CALLS on the server — Release(b.Id), which reads the row and writes itwhat that function read, when it ran. It is the same check the function gets when a tool, an API call or osy run calls it

So a button that calls a server function acts on the row as it is now — a person can release a piece somebody took an hour after the page was opened — and a real race is still refused: somebody writing between that function's read and its commit. To have a save through a server function refused when the PAGE is stale, hand the function what the page saw and compare (Save(id, amount, was)), or make the write on the page.

When somebody else changes a value the page shows#

The page shows the new value — and when a box on the page is bound to it (Field("Amount", value: c.Amount)), the box is marked and the page says, in a notice at its top, "Amount was changed by someone else to 3." The person's save is already based on 3, so if they go on and save 2 it lands as 3 → 2. If they had typed into the box before the change arrived, their text stays in it; the notice tells them what it replaced. Nothing to write for it.

Who changed it?#

Declare who last changed the row — a [ModifiedBy] stamp ([CreatedAt] [CreatedBy] [ModifiedAt] [ModifiedBy]) — and the notice names the person: "Raj changed Owner to Grace.", and the box's tooltip says "Changed by Raj to Grace". The name is the one a refused save names (Changes): the account's DisplayName, else its Email.

The person is named only to a reader who may know it. The page reads the row's stamp, and then that account, as the person viewing it — so a deny read on the stamp, or an account the reader may not read, leaves the notice saying "someone else". Without a [ModifiedBy] the notice always says "someone else".

[ConcurrencyCheck]
entity Case {
  [MaxLength(200)] string? Owner;
  [ModifiedBy] Guid? ChangedBy;             // the platform writes it on every commit

  security {
    allow read, create, update when IsMember;
    deny read ChangedBy when IsContractor;  // a contractor's page says "someone else"
  }
}

demo/concurrency runs both halves through a page (tests/changed-by.test.osy): Ada has the case open, Raj saves a new owner, and her page says "Raj changed Owner to Grace."; a contractor watching the same case is told it was "someone else".

On a page, the person is shown both values#

When the refused write is the page's own and nothing catches it, the page shows the person what they wrote and what the row holds now, per field, with Keep theirs and Apply mine — each made knowing the value, so the save that follows is checked against what they were shown. Nothing to write for it: The conflict notice — when a page's save meets somebody else's change.

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.

Nothing is kept for a row nobody is changing

The value compared against travels WITH the write: each change records what it was based on, and a page's save sends the value it last showed for each field it writes. So the platform keeps nothing per row a page has merely listed — a catalogue page over a thousand guarded rows holds no record of them between requests.

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, the same data Changes hands to your own code. 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.

A workflow's writes are guarded too

The check holds for every write of an app's data, whoever makes it — a page, a function, or a workflow's enter and on blocks, which run as the system — the second writer is refused just as it is on a page.

Not for a quantity#

A number the code only adds to or takes from — stock, a balance, a counter — wants -= and += instead: one statement relative to the stored row, held to the field's [Min]/[Max]. With the check on, the second of two orders for a shirt there are two of is refused as a conflict for nothing; with the atomic form both land, and two orders for the last one cannot both have it. osy lint points it out (data-concurrency-check-on-a-moved-number).

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 the realistic pair is the one the feature exists for: a person holding a page and somebody else who saves the same field after the page last showed it. In a test the browser is the first writer and the test body is the second:

  1. open the page and start the edit in the browser — Ui.Click("Edit"), Ui.Fill(...);
  2. in the test body, make the other person's change and UnitOfWork.Commit() it;
  3. press Save in the browser — its write is based on what the page showed before step 2, so it is refused, and the other person's value stands.

⚠ A test body alone is ONE writer, and cannot stage this. Reading the row twice gives you the same tracked row, and neither runas spelling changes that — a conflict is about when a write settled, never who settled it. And a commit ends what the body "read": after step 2's commit, a variable you read before it no longer carries the old belief, so writing through it refuses nothing. (That is deliberate — one unit of work is one writer, and a false "changed by somebody else" is the failure this feature exists to prevent.) The browser is a writer of its own, which is why the test goes through the page.

⚑ The page below reads its memos once, not live — that is what makes step 2 land after the page's last refresh. A live page is told of step 2 and shows it, so its save is based on the new value and lands: that is the other half of the rule, and a test of it asserts the save and the "changed by someone else" notice instead.

[ConcurrencyCheck]
entity Memo {
  [MaxLength(200)] string Text;
  security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}

[Route("/")]
[Title("Memos")]
[AllowAnonymous]
component MemoPage() {
  var memos = Memo.OrderBy(m => m.Text);
  Memo? editing;
  string draft = "";
  string message = "";

  action Edit(Memo memo) { editing = memo; draft = memo.Text; message = ""; }
  action SaveEdit() {
    if (editing == null) { return; }
    try {
      editing.Text = draft;
      UnitOfWork.Commit();
      message = "Saved.";
      editing = null;
    } catch (ConflictException e) {
      UnitOfWork.Discard();
      message = "Conflict: " + e.Message;
    }
  }

  render {
    Stack(gap: 3, p: 6) {
      foreach (var m in memos) {
        Row(gap: 2) { Text(m.Text); Button("Edit", onPress: () => Edit(m), announceAs: "Edit " + m.Text); }
      }
      if (message != "") { Text(message); }
      if (editing != null) {
        Stack(gap: 2) {
          Field("Draft", value: draft);
          Button("Save", onPress: SaveEdit);
        }
      }
    }
  }
}
[TestFixture]
void Seed() { new Memo { Text = "first" }; }

[Test(Seed)]
void A_browser_saving_over_another_writers_change_sees_the_conflict() {
  Ui.Visit("/");
  Ui.Click("Edit first");
  Ui.Fill("Draft", "mine");            // 1 — the person has started, from what the page read

  var m = Memo.Single();
  m.Text = "theirs";
  UnitOfWork.Commit();                 // 2 — somebody else's save lands

  Ui.Click("Save");                    // 3 — the person's save, based on what the page showed
  Assert.Visible("Conflict:");
  Assert.Equal("theirs", Memo.Single().Text);
}

Without [ConcurrencyCheck] the same test fails where it should: the page says "Saved." and "mine" has overwritten "theirs" — the silent lost update this attribute prevents.

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 from the…

[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]`…

Changes

The lightweight, ungated answer to "who changed this row, and when": field NAMES only, never values, newest first…

[CreatedAt] [CreatedBy] [ModifiedAt] [ModifiedBy]

Row stamps: an entity declares a field and marks it, and the platform writes into it when the row is saved — the…

Atomic updates (-= and +=)

`x.Stock -= n` and `x.Stock += n` on a stored number are written as ONE statement relative to the row as the database…