Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

The conflict notice — when a page's save meets somebody else's change

a page's UnitOfWork.Commit() refused by [ConcurrencyCheck], uncaught → Yours · Now · Keep theirs · Apply mine

When a person's save from a page meets a row somebody else changed while they were editing it, and nothing in the app catches the ConflictException, the page shows them — per field — what they wrote and what the row holds now, and lets them keep the new value or apply theirs over it. Either choice is made knowing the value, so the save that follows is checked against what they were shown. No code to write: an app that catches the conflict decides for itself instead.

stable3 examples compiled by CIuiconcurrencydata

Summary#

Two people open the same record. One saves; the other, still typing, saves after — before their page heard of the first save. With [[entity-concurrency-check|[ConcurrencyCheck]]] the second save is refused rather than silently overwriting the first — and on a page, the person whose save was refused is shown what happened instead of only being told no:

YoursNow
Notewhat they typedwhat the row holds now

…with two buttons: Keep theirs and Apply mine. There is nothing to write for this; it is what a page does when its own save loses a race and nothing in the app catches it.

Signature#

action Save() {
  editing.Note = draft;
  UnitOfWork.Commit();     // refused when somebody else changed Note after this page last showed it
}                          // → uncaught: the page shows the conflict notice

Description#

When it appears#

All three, and only then:

  • the write is the page's own — an action sets a member and calls UnitOfWork.Commit() (or the action's end commits it). A function the page calls on the server writes on what that function read and is not a page edit ([[entity-concurrency-check#whose-read]]);
  • the member is guarded by [ConcurrencyCheck], and somebody else changed it after the page last showed it. A change the page WAS shown — a live read brought it — is not a conflict: the save is based on it and lands, and a box bound to the value has said it changed ([[entity-concurrency-check#changed-elsewhere]]);
  • nothing catches the ConflictException. A try { … } catch (ConflictException e) { … } in the action is the app deciding, and the notice is not shown. An app that draws Failure itself (Failure — what went wrong the last time an action ran) has taken every failure surface over, this one included.

What it shows#

One line per guarded member the save wrote that somebody else changed: Yours is what the person wrote, Now is what the row holds — read for them, under their own rules, at the moment of the refusal. A member they may not read is named but not shown ("changed — you may not see it"), and then only Keep theirs is offered: a value they cannot see cannot be knowingly overwritten.

The row on the page shows the new value at once, whether or not the page reads it live.

Keep theirs#

The other person's value stands. Everything else the person's save carried — other fields, other rows — goes back as pending edits, exactly as it was before they pressed Save; nothing is committed until they save again.

And the page can save that member again. Its next edit is based on the value the person was shown — so a later save lands, and is refused only if the row moves again.

Apply mine#

The person's whole save is put back and committed again, this time over the value they were shown. It is not a blind overwrite: if the row changed a second time meanwhile, the save is refused again and the notice shows the newer value — the person always writes over something they have seen.

It does not cover what it is about#

The notice sits at the foot of the screen and stays until it is answered. While it is up, the page gets room at its foot as tall as the notice, and the row whose save was refused is scrolled clear of it — so the person can read the row and the notice together, and every row can still be reached.

Styling it#

It is plain HTML with osy- classes, like the other built-in surfaces: .osy-action-failed.osy-conflict for the box, .osy-conflict-fields for the table (.osy-conflict-yours, .osy-conflict-theirs for the cells), .osy-conflict-keep and .osy-conflict-apply for the buttons. Restyle them in the app's CSS.

With SaveBar, SaveActions or AutoSave#

The kit's save controls (Save, discard, undo — three ways to show a page's unit of work) commit the page's unit of work the same way, so a save they make that loses a race shows this notice too — they show other refusals themselves ("Not saved — …"), and leave a conflict to it. So does an action of your own that catches everything else and passes the conflict on:

try { UnitOfWork.Commit(); }
catch (ConflictException) { throw; }                 // the notice shows it
catch (Exception e) { message = e.Message; }         // anything else, your sentence

Deciding it yourself instead#

Catch the conflict and the notice is not shown — the app's catch decides, as for any fault ([[entity-concurrency-check#abandon-or-repair]]):

try { editing.Note = draft; UnitOfWork.Commit(); }
catch (ConflictException e) { UnitOfWork.Discard(); message = "Somebody changed that note — have a look first."; }

Examples#

[ConcurrencyCheck]
entity Recipe {
  [Required, MaxLength(60)] string Title;
  [MaxLength(200)] string? Method;
  security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}

[Route("/")]
[AllowAnonymous]
component Recipes() {
  var recipes = Recipe.OrderBy(r => r.Title);         // read once: a change made after it is a race (see below)
  Recipe? editing;
  string draft = "";

  action Edit(Recipe r) { editing = r; draft = r.Method ?? ""; }
  action Save() {
    if (editing == null) { return; }
    editing.Method = draft;
    UnitOfWork.Commit();
    editing = null;
  }

  render {
    Stack(gap: 3, p: 6) {
      foreach (var r in recipes) {
        Row(gap: 2) {
          Text($"{r.Title}: {r.Method ?? ""}");
          Button("Edit", onPress: () => Edit(r), announceAs: "Edit " + r.Title);
        }
      }
      if (editing != null) {
        Stack(gap: 2) {
          Field("Method", value: draft);
          Button("Save", onPress: Save);
        }
      }
    }
  }
}
[TestFixture]
void Kitchen() { new Recipe { Title = "Bread", Method = "knead" }; }

[Test(Kitchen)]
void A_lost_race_shows_both_values_and_apply_mine_lands() {
  Ui.Visit("/");
  Ui.Click("Edit Bread");
  Ui.Fill("Method", "fold, do not knead");      // the person starts, from what the page read

  var r = Recipe.Single();
  r.Method = "knead twice";
  UnitOfWork.Commit();                          // somebody else's save lands first

  Ui.Click("Save");                             // the person's save loses the race
  Assert.Visible("Somebody else changed this Recipe while you were editing it.");
  Assert.TextIs("fold, do not knead");          // Yours
  Assert.TextIs("knead twice");                 // Now

  Ui.Click("Apply mine");
  Assert.Visible("Bread: fold, do not knead");
  Assert.Equal("fold, do not knead", Recipe.Single().Method);
}

[Test(Kitchen)]
void Keep_theirs_leaves_their_value_and_the_page_can_save_it_again() {
  Ui.Visit("/");
  Ui.Click("Edit Bread");
  Ui.Fill("Method", "fold");

  var r = Recipe.Single();
  r.Method = "knead twice";
  UnitOfWork.Commit();

  Ui.Click("Save");
  Ui.Click("Keep theirs");
  Assert.Equal("knead twice", Recipe.Single().Method);

  Ui.Click("Edit Bread");
  Ui.Fill("Method", "knead twice, then rest");
  Ui.Click("Save");
  Assert.Visible("Bread: knead twice, then rest");
}
using Osysharp.Ui;

[ConcurrencyCheck]
entity Lamp {
  [Required, MaxLength(60)] string Name;
  [MaxLength(200)] string? Fuel;
  security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}

[Route("/lamps/{id}")]
[Render(CSR)]
[AllowAnonymous]
component LampPage(Guid id) {
  var lamp = Lamp.FirstOrDefault(p => p.Id == id);   // read once, so a save after another one is a race
  render {
    if (lamp != null) {
      Stack(gap: 3, p: 6) {
        Text(lamp.Name + ": " + (lamp.Fuel ?? ""));
        Field("Fuel", value: lamp.Fuel);
        SaveBar(saved: "Kept.");
      }
    }
  }
}

[Test]
void A_lost_race_under_a_save_bar_shows_both_values() {
  var hall = new Lamp { Name = "Hall lamp", Fuel = "paraffin" };
  UnitOfWork.Commit();
  Ui.Visit("/lamps/" + hall.Id);
  Ui.Fill("Fuel", "olive oil");                // the person starts, from what the page read

  var other = Lamp.Single();
  other.Fuel = "lamp oil";
  UnitOfWork.Commit();                          // somebody else's save lands first

  Ui.Click("Save");
  Assert.Visible("Somebody else changed this Lamp while you were editing it.");
  Assert.TextIs("olive oil");                  // Yours
  Ui.Click("Apply mine");
  Assert.Equal("olive oil", Lamp.Single().Fuel);
}

See also#

Related

ConcurrencyCheck

Plain optimistic concurrency: refuses an update whose value was changed by somebody else after the value the write was…

Failure — what went wrong the last time an action ran

When an action fails — a security denial, a constraint violation, a server that refused — the platform records why, and…

Save, discard, undo — three ways to show a page's unit of work

Every edit on a page joins the route's unit of work; how the page OFFERS to keep it is a choice of presentation…

UnitOfWork

Every body accumulates its writes in a unit of work rather than sending them one at a time. UnitOfWork.Commit() makes…

try / catch / finally

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