Counters and other one-step updates
When a new value depends only on the stored one, the database works it out in one step and nothing needs checking. A view counter is the usual case:
action view_video(video_id: Video.id) fails VideoNotFound -> Video { return attempt Video.update(video_id) { set: { view_count: ViewCount(Video.view_count + 1) last_viewed_at: clock.now } }}
On the right of set:, Video.view_count means the value stored at the
moment of the write, while video.view_count would be the copy loaded
earlier. Such an update takes the record's id instead of a loaded
record. Its values may use only parameters, constants
(Constants), clock.now and the record's stored
fields, so no increment is ever lost.
The statement is UPDATE "video" SET "view_count" = ("view_count" + $1), ...,
and no row is VideoNotFound.
The database works a stored field out only with + and -, which give
the same result in SQL as in Jadpo (+ joining text is written ||).
They must sit inside a construction the declared ranges prove:
ViewCount(...) here, since a count of at least 0 plus 1 is at least 0.
Anything else needs the record loaded first.
A decimal added to a stored field may not have more decimal places than
the field's scale, since PostgreSQL would round the sum its own way
(half away from zero) rather than Jadpo's. A literal counts by its own
places and any other value by its type's scale. A value whose type sets
no scale is refused (data.stored_decimal_scale).
Which isolation level an action runs at
Which isolation level an action runs at follows from what it does. You write nothing for it: the compiler decides it from the graph.
An action that loads no record, neither itself nor through any action it calls, runs at PostgreSQL's read committed level. Of two requests counting the same row at once, the second waits for the first, adds to its count and commits, and neither reruns.
An action that loads a record runs at repeatable read (Isolation levels). So a counter racing another request on a row it loaded reruns the action, as any concurrent change does.
An action loads a record when it:
- reads one:
get, or a query, with what it includes; - is handed one: a parameter whose type holds a record, such as
video: Video, or a list or object holding one; - writes one back: an
update,deleteor transition of a record, rather than of itsid; - runs a transition whose
when:can refuse a visible record, since telling that refusal from a missing record takes a second statement, which must see the first one's snapshot.
A create, and an update, delete or other transition by id, counters
among them, loads nothing: the store checks a reference a create sets in
the same statement. Calling a function never loads a record.
jadpo check decides each action and fixture from these and the actions
and fixtures it calls. It writes the level into the manifest as
isolation (read_committed or repeatable_read), and the runtime
begins the transaction at it (BEGIN ISOLATION LEVEL READ COMMITTED or
REPEATABLE READ). An action another action calls joins that action's
transaction at its level, which already counts what the callee does. Each
action a test calls begins its own.
So view_video above runs at read committed. Two requests counting one
video at once both commit at their first attempt, the second waiting for
the first's lock and adding to its count. A counter in an action that
read the video first still reruns the second. Both are checked live
against PostgreSQL in crates/jadpo-cli/tests/writes.rs.
What runs outside an action, such as a read a test makes itself, migrations and purge, runs at repeatable read.
Markdown for agents: /docs/counters-and-other-one-step-updates.md.Built from Jadpo main at a707efb.