Summary#
A function's writes normally join its caller's unit of work: they land when the caller's work
is committed, and are thrown away with it when the caller fails. [CommitsAlone] is for the writes that must not
follow the caller:
entity Attempt {
[Required, MaxLength(200)] string What;
security { allow create, read when IsAuthenticated || IsAnonymous; }
}
entity Customer {
[Required, MaxLength(200)] string Name;
security { allow create, read when IsAuthenticated || IsAnonymous; }
}
[CommitsAlone]
void NoteAttempt(string what) { new Attempt { What = what }; }
void Import(string name) {
var customer = new Customer { Name = name };
NoteAttempt("import " + name); // committed here, by itself
if (name == "") { throw new ValidationException("A customer needs a name."); }
}Signature#
[CommitsAlone]
<Type> <Name>(<value parameters>) { … } // a top-level function; values in, a value outDescription#
What happens at the call?#
The function gets a fresh, empty unit of work. Its body runs in it, as the caller — the same person, the same rules. When it returns, that unit of work is committed, atomically, before the caller's next statement runs.
| the function's writes | the caller's pending writes | |
|---|---|---|
| the function returns | committed, at once | untouched — still pending |
| the function throws | discarded | untouched; the caller may catch and go on |
the caller later fails or calls UnitOfWork.Discard() | still committed | discarded |
| the caller later commits | already committed | committed |
What does it see?#
The database as it is stored. It does not see rows its caller created or changed and has not committed, and it never commits them. Once it has returned, the caller sees what it wrote like any other stored row — and that includes a row the caller already holds: a variable read before the call reads the stored value after it. A change of the caller's own that is still pending on that row is kept, on top of what was stored.
Why values, and not rows?#
A row belongs to the unit of work that read it. So the function takes and returns values — pass a row's Id and read
the row inside. A row-typed parameter or return is refused when you compile, saying so.
entity Tally {
[Unique, MaxLength(40)] string Name;
int Count = 0;
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}
[CommitsAlone]
int CountOne(Guid tallyId) {
var tally = Tally.Single(t => t.Id == tallyId);
tally.Count = tally.Count + 1;
return tally.Count;
}Can a task call it?#
Yes — and it is the way a task writes. A [[function-async-await#writes|task may not write]] to its caller's unit of
work, because the two would race; a function with a unit of work of its own has nothing to race over. Each call
commits by itself, so calls running at the same time are separate transactions: guard a value two of them may change
with [[entity-concurrency-check|[ConcurrencyCheck]]] and retry the loser.
void NoteAll(List<string> items) {
Parallel.ForEach(items, item => NoteAttempt("seen " + item));
}Does except via still apply?#
Unchanged. A function a deny … except via names may also be [CommitsAlone]: its own
statements write past that deny, a helper's do not, and the caller still needs the grant.
What about a workflow body?#
It works there, as the kit's or the app's code that wrote it. Treat the call as you would an outbound call: a run that
is interrupted after the function committed and before the run recorded its own progress calls it again when it
resumes. Where once matters, give the row a [Unique] key the second call collides on.
An event the function raises is committed with it, and its handlers run when the body's step has ended, with the step's other events — not when the function returns ([[function-raise#in-a-step]]).
What is refused?#
| you wrote | what the compiler says |
|---|---|
| a row parameter, a row return, or a list of rows | a row belongs to the unit of work that read it — take or return its Id |
async, or a Task return | it commits when it returns, so it must have finished by then |
a stream<T> producer | a producer has no single return to commit at |
| a class's method | a unit of work of its own belongs to a top-level function |
[Client] beside it | it runs on the server, where the commit happens — the attribute places it there, so write no side |
[OwnUnitOfWork], its former name | [OwnUnitOfWork] is now [CommitsAlone] — the same meaning under the new name |
At run time, a function that has to wait part-way — a dialog in the browser, a workflow, a durable delay — is refused with the same reason: do the waiting in the caller and hand the function what to write.
Examples#
The rows are there after the failure, proved by a test:
[Test]
void The_attempt_outlives_the_failed_import() {
Assert.Throws<ValidationException>(() => Import(""));
Assert.Equal(1, Attempt.Count()); // NoteAttempt's row is stored
Assert.Equal(0, Customer.Count()); // the import's own row went with the failure
}
[Test]
void A_counter_is_changed_by_id() {
var tally = new Tally { Name = "visits" };
UnitOfWork.Commit();
Assert.Equal(1, CountOne(tally.Id));
Assert.Equal(2, CountOne(tally.Id));
}
[Test]
void A_row_the_caller_holds_reads_what_the_function_stored() {
var tally = new Tally { Name = "held" };
UnitOfWork.Commit();
CountOne(tally.Id);
Assert.Equal(1, tally.Count); // the variable from before the call
Assert.Equal(1, Tally.Single(t => t.Name == "held").Count); // …and the row read again
}
[Test]
void A_change_the_caller_has_pending_on_that_row_is_kept() {
var tally = new Tally { Name = "pending" };
UnitOfWork.Commit();
tally.Name = "renamed"; // the caller's own, not yet saved
CountOne(tally.Id);
Assert.Equal("renamed", tally.Name);
Assert.Equal(1, tally.Count);
UnitOfWork.Commit();
Assert.Equal(1, Tally.Count(t => t.Name == "renamed" && t.Count == 1));
}
[Test]
void A_loop_of_tasks_writes_one_row_each() {
NoteAll(["a", "b", "c"]);
Assert.Equal(3, Attempt.Count());
}See also#
- UnitOfWork — the unit of work every other function shares with its caller
- try / catch / finally — what a caught fault keeps and discards
- async / await and Task — tasks, and why they do not write their caller's rows
- deny … except via — a row only one function may write
- ConcurrencyCheck — two writers of one value