Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Reads and writes

Reads and writes

All-or-nothing actions and concurrent changes

On this page

Every action is all-or-nothing (Functions and actions): its writes, and those of the public actions it calls, commit together or not at all. You write nothing for this.

A write to a record loaded earlier in the action checks that nobody changed the record in between. If someone did, the runtime throws the attempt away and runs the whole action again, with fresh reads and a fresh clock.now:

text
request A: loads todo 7                 request B: loads todo 7request A: updates todo 7, commits                                        request B: updates todo 7: changed since loaded                                        request B: runs again, loads the new todo 7, updates

Running it again is safe because an action's outside call comes before its writes and carries an idempotency key, a value that lets the provider recognise a repeat. So a rerun repeats the call harmlessly (Integrations). There are two exceptions:

  • A changing call with no key. Once it has gone out, the action isn't run again. It stops as outcome_unknown, for an operator to look at, since sending it again could act twice (see "Parked, never sent again blindly" in The seam every outside call goes through).
  • An action with several changing calls. Once its record of the calls exists, a rerun replays the recorded calls and keeps the record's clock.now (Several outside calls, below).

After three attempts within a second, the retry budget, the request fails unavailable and the client may try again.

To catch a client that edits a stale copy across two requests, use HTTP's If-Match (HTTP).

Isolation levels

On PostgreSQL, an action that loads a record runs at the repeatable read isolation level, where the whole transaction sees one snapshot of the database. The runtime already retries its "could not serialize" error. An action that loads no record runs at read committed instead, where each statement sees what was committed before it began (Counters and other one-step updates, below).

At repeatable read, the snapshot is taken at the action's first statement, and a record it loaded is the record as of then. An update or delete of a row another transaction changed since then is PostgreSQL's serialisation failure, which the driver reports as a concurrent change.

The runtime then rolls the transaction back, throws away the attempt's events, and runs the whole action again from its first line. Its reads see the other transaction's change, and its new records get new identities. The budget is three attempts.

A record handed to an action from outside its transaction, as when a test passes in one an earlier action returned, is checked only for its row still being there.

Outside calls hold no transaction

No transaction is open while an action waits on a provider. What an action reads before its outside call runs in a short read-only snapshot, which closes before the call goes out. The transaction opens after the call returns, and the action's writes and any later reads run in it. Reads between two outside calls get a snapshot of their own.

A write to a record loaded before the call checks that its row is unchanged since it was loaded, by comparing PostgreSQL's row version, xmin. A change is a concurrent change like any other: the whole action runs again, and the call repeats harmlessly under its idempotency key.

So a slow provider holds no database connection, and the time it takes never widens the window in which another request's change forces a rerun. The guarantee is the one repeatable read gives: every record the action writes is checked against the copy it loaded.

This is done where every outside call goes through Jadpo's runtime (The seam every outside call goes through):

  • Reads before a call are a snapshot. An action's transaction still begins at its first store operation, at its isolation level. If the action has run only reads when a call goes out, that transaction is the snapshot. It reads the row versions of the records it loaded (one statement per table, xmin by id) and rolls back, before the call is sent. Once the action has written, a call is refused (call_in_transaction), so no transaction is ever open while a call is out. An action with no call keeps its one transaction.
  • The transaction after the call. The next store operation begins a new transaction at the same level, in the same store. Reads after a call run in it. If another call comes before any write, those reads were a snapshot of their own, and it closes the same way.
  • The row version check. An update, delete or transition of a row loaded in a snapshot that a call closed compares the row's version in the write's own WHERE. A successful write locks the row and checks it in one statement, once per transaction. When no row is changed, a FOR NO KEY UPDATE probe, run only on that failure, tells whether the row has a different version or is gone, which is a concurrent change. It runs before the usual refusals for policy, a field, a lifecycle condition or a missing row.
  • Nested actions and writes by id. A failed nested action's savepoint restores only the version checks whose locks survive its rollback. A write by id is checked too when the action loaded that row before the call, as repeatable read checks any write to a row changed since the snapshot. A row is held to the version the action first loaded, so a row read again after the call, or in a second snapshot, is still checked against that first version.
  • A change reruns the action. A concurrent change throws the attempt away and runs the action again, within the budget of three attempts. Its keyed calls take the same steps again, so each repeats under the key it was first sent with (Integrations). A change found at the commit of a route's or a delivery's held transaction does the same. A changing call without a key that went out stops the action as outcome_unknown instead. A reference to a record loaded before a call, deleted while the call was out, is a concurrent change too, so the rerun's fresh read finds the record gone.
  • A batch's items. An item of a run whose each: action makes outside calls is a batch of its own (How a run is kept). Its calls go out with no transaction open, as any action's do, and its writes and the run's position commit after them under these rules. A write placed before a later call is refused at compile time (Several outside calls, below).

Several outside calls

An action may make up to 8 changing calls, one after another (Several changing calls in one action). For reads and writes this changes three things and keeps the rest:

  • Writes come after the last call. The action's transaction opens only once its last outside call has returned; a write placed before a later call is a compile error naming the call it must follow. No transaction is open during any call, as above, and reads between two calls each get a snapshot of their own.
  • The runtime keeps its record apart from the action's transaction. Each step is recorded before it is sent and its answer after, each in a short transaction of the runtime's own in the store the action writes; the record is deleted in the action's commit, so a committed action leaves none behind.
  • A rerun replays, with the record's time. When a concurrent change reruns the action, recorded steps are replayed without being sent and clock.now is the time the record holds, not a fresh one; the reads are fresh. If a fresh read changes a request the action would send, the rerun stops as diverged before sending anything, held for an operator (Jobs and events), rather than sending a different request under a used key. Database reads are never recorded, so this check is what keeps a rerun honest.

The writes still commit together after every call has answered. So a write that is refused for good, by a unique rule or a restricted reference, after a step took effect is warned of at compile time, naming the fixes: check with a read before the first call, record what happened and finish in a subscriber, or undo it as another step (Jobs and events).

A write before a call

A write placed before a later outside call is data.write_before_call. It is reported at the write and names the last call the write comes before, so checked code never reaches the runtime's call_in_transaction refusal, which stays as the guard. Each part of the rule takes the most cautious reading:

  • What writes. An entity's write; a session or key operation that changes one; starting a run (batches.each and the file helpers keep its row) or cancelling one; and calling an action or fixture that does any of these, itself or through an action it calls, since it joins the caller's transaction. emit writes nothing until the action commits, and batches.status only reads.
  • What calls. A provider's operation, a store's put or read, and calling an action or fixture that makes one, itself or through an action it calls. A run's each: and finished: are the run's, never the action that starts it.
  • Each way through on its own. A write on one way through an if or a match and a call on another never meet, and a return, reject or propagate ends its way; a loop's body counts as running more than once, so a write in it comes before a call after the loop or in its next turn.
  • Not declinable. The order is never a judgement call (Declining a judgement call): run as written, the call would be refused.

A bakery keeps the price sheet a supplier sends again, marking it checked: the call first, then one write after it.

example
use app { sheets }use bakery/entities { PriceSheet }use bakery/types { SheetText }use bakery/failures { SheetNotKept }/// Keeps the sheet a supplier sent again, then marks it checked: the/// call first, the write after it.action recheck_prices(    price_sheet: PriceSheet,    sheet_text: SheetText) fails SheetNotKept -> PriceSheet {    var sheet: File = attempt sheets.put(encoding.to_utf8(sheet_text), FileType.csv) {        storage_full: SheetNotKept    }    return PriceSheet.update(price_sheet) {        set: {            sheet: sheet            checked: true        }    }}

Markdown for agents: /docs/all-or-nothing-actions-and-concurrent-changes.md.Built from Jadpo main at a707efb.