Osy#betaRun it on Osyrin: osyrin.com

Reference / Entity

Atomic updates (-= and +=)

row.Number -= n; row.Number += n; // one statement, held to the field's [Min]/[Max]

`x.Stock -= n` and `x.Stock += n` on a stored number are written as ONE statement relative to the row as the database holds it, refused when it would pass the field's [Min] or [Max] — so concurrent changes each take their share, nothing is lost, and the last unit cannot be sold twice.

stable2 examples compiled by CIentitymodelconcurrencyvalidation

Summary#

Writing x.Stock -= n (or += n) on a stored number does not write the number you computed. It writes the change: one statement, stock = stock - n, relative to the row as the database holds it at that instant, and refused when the result would pass the field's [Min] or [Max]. Two orders for a shirt there are two of both land; two orders for the last one cannot both have it — the second is refused with the field and its bound named. No read, no retry, no lock.

Every other write is last writer wins, exactly as before: x.Stock = 5 writes 5.

Signature#

row.Number -= amount;            // also: row.Number = row.Number - amount;  row.Number--;
row.Number += amount;            // also: row.Number = row.Number + amount;  row.Number++;

Description#

What it writes

For a field declared [Min(0)] int OnHand;, the line lot.OnHand -= n; commits as

UPDATE lot SET on_hand = on_hand - @n WHERE id = @id AND on_hand - @n >= 0 RETURNING on_hand

Deciding and writing are one statement, so there is no window between them: whatever another request committed a moment earlier is what the subtraction starts from. The value the code read is never written back, so a stale read cannot undo somebody else's change.

The bound is the field's own [Min] and [Max]#

declared-= n is refused when+= n is refused when
no [Min], no [Max]never — it may go as far negative as it likesnever
[Min(0)]the result would be below 0never
[Min(-10)]the result would be below −10 — an overdraft down to tennever
[Max(100)]neverthe result would be above 100
[Range(0, 100)]below 0above 100

A [Min] or [Max] with a when condition is checked as an ordinary rule at commit, not by the statement.

What counts as an adjustment

The left side and the read on the right are the same field of the same row, and the field is a stored, non-nullable int, long, decimal or double:

writtenis
lot.OnHand -= n; · lot.OnHand = lot.OnHand - n; · lot.OnHand--;an adjustment
lot.OnHand += n; · lot.OnHand = lot.OnHand + n; · lot.OnHand++;an adjustment
lot.OnHand = counted;a plain write — last writer wins
lot.OnHand = other.OnHand - n;a plain write — it reads another row
lot.OnHand = lot.OnHand * 2;a plain write — only + and - are relative

In the unit of work

Reading the field after an adjustment answers the value you would expect — what was read, plus the change — and two adjustments of one field in one unit of work commit as their sum. A plain write after an adjustment replaces it: the row then commits the number you wrote. UnitOfWork.Discard() and a savepoint's undo take an adjustment back with the rest. After the commit the row holds what the database answered, which includes everybody else's changes — so lot.OnHand read after UnitOfWork.Commit() is the shelf as it really is.

A page action that writes row.OnHand -= 1 sends the change, not the number, so it reaches the server as the same statement. Security applies to it exactly as to any update of that field.

What happens when a change would pass the bound?#

A change that would pass the bound is refused at its commit with a ValidationException naming the field and the bound — "OnHand must be at least 0.", or the message you declared ([Min(0, "There are not that many left.")]). Nothing of that commit is written. Catch it like any other refused write, and discard what failed ([[function-throw#the-refused-row]]):

try { lot.OnHand -= n; UnitOfWork.Commit(); }
catch (ValidationException e) { UnitOfWork.Discard(); return e.Message; }

In a workflow, a refusal the code's own read could not have seen coming — the stock was there when it looked, and gone when it wrote — is a race, not a rule, so the step is run again: it reads afresh, finds the stock gone, and takes the path you wrote for that.

[ConcurrencyCheck] on a number is usually the wrong tool#

[[entity-concurrency-check|[ConcurrencyCheck]]] refuses a write whose field changed since it was read. On a record people edit that is exactly right; on a quantity it refuses the second of two orders that could both have been served. An adjustment still honours a declared check — some numbers are lock tokens that exist to make two writers meet — so the remedy for a quantity is to drop the check and declare the floor. osy lint says so (data-concurrency-check-on-a-moved-number): a checked number the code only adds to or takes from, reads, never sets outright, and never bounds by another column of its own row.

Who took how much: a row per move#

The running total answers how many; it cannot answer who took two on Tuesday. For that, write a row for each move beside the total, in the same commit — one function that moves the number and records why, so no caller can do one without the other. The shop kit does this: MoveStockAt adjusts the variant's and the location's numbers and writes a StockMove (the change, what it held, the reason, the order or transfer, and who), so Variant.Stock is always the running total of its moves.

Examples#

entity Lot {
  [Required, MaxLength(40)] string Code;
  [Min(0, "There are not that many left.")] int OnHand = 0;
  /// An account that may run ten into debt, and no further.
  [Min(-10)] decimal Tab = 0;
  security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}

/// Takes `n` from the shelf — refused, with the declared sentence, when there are fewer than `n` left at commit.
[Server, AllowAnonymous]
string Take(Guid id, int n) {
  var lot = Lot.Single(x => x.Id == id);
  try {
    lot.OnHand -= n;
    UnitOfWork.Commit();
  } catch (ValidationException e) {
    UnitOfWork.Discard();
    return e.Message;
  }
  return $"{lot.OnHand} left";
}

[Server, AllowAnonymous]
void Charge(Guid id, decimal amount) {
  var lot = Lot.Single(x => x.Id == id);
  lot.Tab -= amount;                       // down to −10; past that the commit is refused
  UnitOfWork.Commit();
}
enum MoveReason { Received, Sold, Counted }

entity Bin {
  [Required, MaxLength(40)] string Code;
  [Min(0)] int OnHand = 0;
  security { allow read, create, update when IsAuthenticated; }
}

entity BinMove {
  [Required] Bin Bin;
  int Change;
  MoveReason Reason;
  [CreatedAt] DateTime At;                 // when, and who — written by the platform
  [CreatedBy] Guid? By;
  security { allow read, create when IsAuthenticated; }
}

/// The ONE writer of a bin's number: it moves the total and records why, in the same commit.
[Server, Authorize(IsAuthenticated)]
void Move(Guid id, int change, MoveReason reason) {
  var bin = Bin.Single(x => x.Id == id);
  bin.OnHand += change;
  new BinMove { Bin = bin, Change = change, Reason = reason };
  UnitOfWork.Commit();
}

See also#

Related

ConcurrencyCheck

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

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…