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_handDeciding 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 likes | never |
[Min(0)] | the result would be below 0 | never |
[Min(-10)] | the result would be below −10 — an overdraft down to ten | never |
[Max(100)] | never | the result would be above 100 |
[Range(0, 100)] | below 0 | above 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:
| written | is |
|---|---|
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#
- ConcurrencyCheck — refusing a stale write to a record people edit
- constraints —
[Min],[Max]and[Range], the bounds an adjustment is held to - throw —
ValidationException, and discarding a refused write - try / catch / finally — catching it
- entity — where the attributes go