Update takes the row's lock, reads the row and merges onto it (#457), but
until #649 it could not be made conditional. "Apply this only if version is
still 7" or "add 1 to attempts" meant reading the row first and writing
afterwards, which is the lost-update race #593 forbids. Two fields on
EntityOperation close that gap:
Preconditionsis a condition in the Condition DSL that must hold on the locked row, or the write writes nothing.UpdateOpscomputes values from the locked row (increment) or from the transaction clock (server_time).
They apply to EntityManager.Update and Delete and to Tx.Update and
Tx.Delete (Transactions). The API is Go only: both fields
are json:"-", and the HTTP carriers are #655. Tx.AssertRow evaluates a
precondition by the same rules on a row it locks without writing it
(Locking a row).
_, err := tx.Update(ctx, &forma.EntityOperation{
EntityIdentifier: forma.EntityIdentifier{SchemaName: "outbox_message", RowID: id},
Updates: map[string]any{"status": "failed", "last_error": msg},
Preconditions: &forma.KvCondition{Attr: "lock_generation", Value: "equals:4"},
UpdateOps: []forma.UpdateOp{
forma.Increment("attempts", 1),
forma.ServerTime("next_attempt_at", backoff),
},
})
switch {
case errors.Is(err, forma.ErrPreconditionFailed): // fenced out: the row has moved on
case errors.Is(err, forma.ErrNotFound): // the row is gone
}-
Intake, before any SQL. The fields are checked against the schema (rules below). A refusal is
ErrInvalidInput. -
Render, before any SQL. The precondition is rendered to SQL. A DSL error, such as an operator the attribute's type does not take (
starts_withon auuid), an operand that is not of its type (a malformed UUID, text with a NUL character or invalid UTF-8) or an emptyinlist, is the DSL's ownErrInvalidInput. -
Lock. The write begins its transaction and takes the row's advisory lock, as every single-row write does.
-
Check. One statement, run after the lock is granted, evaluates the precondition on the row. No row is
ErrNotFound. A row that does not satisfy it isErrPreconditionFailed. Either way nothing is written: noentity_main, EAV orchange_logchange, and no version stamp. -
Write. An update reads the row, merges
Updatesand theserver_timevalues onto it, appliesincrementto the result, validates, and writes through the ordinary merge path with its version stamp andchange_logentry. A delete deletes.A
server_timereplaces its attribute as a value inUpdatesdoes: the stored value is never decoded, so the operator also rewrites a stored value the read refuses (see Schema consistency migration). Anincrementdecodes the value it adds to.
An operation without Preconditions skips step 4, and one without UpdateOps
skips the operators; both behave exactly as before #649.
- Same DSL as
Query. Leaves,and/orcomposites,in,not_in,is_null,is_not_nulland$nowall work as in a query.nilmeans no precondition. - Schema attributes only. A leaf naming anything the schema does not
define is refused, raw
entity_maincolumns included. In particular the system stampltbase_updated_atis not a precondition: optimistic concurrency uses an attribute the schema declares, such asversion. A schema attribute whose name is itself anentity_maincolumn (an attribute calledtext_02, say) is refused too. The DSL reads such a name as the column, whatever the schema stores under it, so the precondition would test a different value. Registration still admits these names (#470). - Matches
Query. A precondition holds exactly when a hot-tierQuerywith the same condition would return the row at the moment after the lock is granted. The check is rendered by the rendererQueryuses (buildHybridConditions). It is anchored on theentity_mainrow, whereQuerymay anchor oneav_data. The anchor does not change which rows match.Queryanchors oneav_dataonly when no part of the condition can hold for an entity without EAV rows, which that anchor never visits: a column-bound leaf, anis_nullleaf or an emptyandgroup (which matches every row) forces theentity_mainanchor. - What a condition means is the DSL's rule, not
Query's answer. MatchingQuerymakes a precondition exactly as right asQuery, and no more: the two share the renderer, so a defect in it is in both. Before #677 a fractional operand against anintegercolumn was truncated, soequals:7.9held on a row holding 7 and authorized the write. The tests therefore judge preconditions against exact arithmetic over the stored values and Go string matching, never againstQuery; the operand rules are in condition-dsl.md. - Update and Delete only.
Preconditionson aCreateis refused: there is no current row. Existence-conditional create isTx.Upsert(#651).
A missing row is ErrNotFound, whatever the precondition says, including a
precondition no row could satisfy. A present row that fails the precondition
is ErrPreconditionFailed, never ErrNotFound. Callers can therefore tell
"someone else moved the row on" from "the row is gone" without a second read.
The published message is precondition does not hold for entity (row=<id>)
(HTTP 412 once #655 routes the fields).
| Operator | Attribute types | New value |
|---|---|---|
forma.Increment(attr, by) |
smallint, integer, bigint |
the locked value plus by |
forma.ServerTime(attr, offset) |
date, datetime |
the transaction's now(), truncated to milliseconds, plus offset |
Refused at intake, before any SQL, with ErrInvalidInput naming the
attribute:
UpdateOpson aCreateor aDelete;- an attribute that is not in the schema, or one under an
x-relationroot (derived on read, never written); - an attribute bound to a system column (
ltbase_row_id,ltbase_schema_id,ltbase_created_at,ltbase_updated_atorltbase_deleted_at). Such a binding is a read-only alias: the store sets the column itself and drops any value an update sends for it, so the operator's result could not be stored. A literal value for it inUpdatesis still accepted and ignored, as before; - an attribute inside an array the JSON Schema declares, such as
items.seenAtwhenitemsis an array of objects. An operator writes one value, and such an attribute has one per element. The refusal does not depend on the row: where the row has noitems, the operator would create an object the schema says is an array. An array the manager cannot see in the schema (one behind a$ref, or any array when the manager has no validator) is judged on the locked row instead; incrementon any other type, or with anOffset;server_timeon any other type, with aBy, or with an offset finer than a millisecond;- two operators on one attribute;
- an attribute that
Updatesalso writes, in either spelling ("a.b"or{"a": {"b": …}}), or through a containerUpdatesreplaces ("a": niloverlaps an operator ona.b).
An update may carry UpdateOps without Updates.
Judged against the locked row, after the write has sent SQL, also
ErrInvalidInput:
incrementon a row with no value for the attribute. There is no implicit zero: write an initial value first.incrementwhose result leaves the type's range. The message names the attribute, the type and the bound, for exampleincrement by 1 on smallint attribute 'n' would take its value 32767 past the smallint maximum 32767.- an attribute that
Updateswrites on this row only. An object inUpdatesmerges into an object the row holds and is placed where the row holds nothing, but replaces a list or a scalar, and everything beneath it. On a row whoseais a list or a scalar,{"a": {"x": 1}}therefore replaces it anda.bwith it, and overlaps an operator ona.b. The check runs before the merge, while the row's value is still there to judge. - an operator on an attribute inside an array the row holds, where intake could not see the array in the schema: it would write one value where the row holds one per element.
| Attribute | Value written |
|---|---|
datetime (EAV, or bound to a unix_ms column) |
the instant, in milliseconds |
datetime bound to an iso8601 text column |
the instant floored to the second, the precision the column stores |
date |
the UTC calendar day of the instant, the day $now names on a date |
The offset applies before the floor: ServerTime("day", -9*time.Hour) at
08:15 UTC writes yesterday's date.
server_time is PostgreSQL's now() in the write's own transaction: the
transaction's start, the same instant $now reads in a precondition. In one
Tx, every server_time and every $now resolves to that one instant,
however long the Tx runs, so a Tx can write ServerTime("seen_at", 0) and
later test seen_at equals:$now. The instant never comes from the process
clock.
The version stamp ltbase_updated_at is not that instant. It still comes from
the process clock, kept strictly increasing per row (#274), so it may differ
from a server_time written by the same update.
BatchCreate, BatchUpdate and BatchDelete, atomic or best-effort, refuse
an operation that carries Preconditions or UpdateOps before any write,
with ErrInvalidInput naming the operation's index. Run conditional writes
one at a time, or several in one Transactor.InTx. Multi-row conditional
mutation is #652.
ErrPreconditionFailed and ErrNotFound are expected outcomes produced
without a failed statement, so they leave the Tx usable: fn may handle
them and continue, or return them to roll back everything. Intake and
rendering refusals also come before any SQL and do not poison. The refusals
judged against the locked row (a missing value, an overflow, an overlap or an
array only the row shows) come after the write has sent SQL, and poison the
Tx like any other ErrInvalidInput after SQL. See Poisoning.
Writers of one row queue on its advisory lock. The check runs after the lock
under READ COMMITTED, so its snapshot postdates the previous holder's commit:
a writer that waited judges the row the winner left. Two writers that both
expect version = 7 and increment it therefore produce one commit and one
ErrPreconditionFailed, and version ends at 8. Concurrent increments
without a precondition also serialize on the lock and lose nothing.
A conditional Delete runs at READ COMMITTED for the same reason. An
unconditional Delete keeps the session's default isolation, as before.
This section describes the aurora-dsql engine (#633,
Aurora DSQL). CI runs it on PostgreSQL emulating DSQL. It
has not yet run on a cluster. DSQL has no advisory locks and runs at
REPEATABLE READ with optimistic concurrency, so a losing writer may evaluate
its precondition on the snapshot from before the winner committed, see it
hold, and write. Both writers wrote the row's guard row, so DSQL aborts one of
the two transactions with 40001. The contract is therefore stated as an
outcome that holds on both backends:
- At most one of the conflicting writers commits.
- Every other writer receives
ErrPreconditionFailedorErrRetryableConflict. A retry of anErrRetryableConflictstarts from a fresh snapshot and then reportsErrPreconditionFailed.
Forma does not retry 40001 internally (Retrying).
The precondition and operator semantics are backend-neutral. The guard-row
write replaces the advisory lock behind the locking seam #648 introduced.
Tests that assert ErrPreconditionFailed for the loser specifically are named
_PostgresOnly.
| Property | Test |
|---|---|
Precondition matches Query, every operator, on column-bound and EAV attributes |
TestPreconditionHoldsExactlyWhenQueryReturnsTheRow |
A refused update or delete leaves entity_main, EAV and change_log unchanged |
TestFailedPreconditionWritesNothing |
An object in Updates that would replace the row's array refuses the operator beneath it and writes nothing |
TestUpdatesReplacingARowArrayWritesNothing |
An operator on a system-column alias is refused before SQL: no table changes, and a Tx stays usable |
TestOperatorOnASystemColumnAliasIsRefusedBeforeAnyStatement |
A uuid precondition with a LIKE operator or a malformed operand is refused before SQL, by Query, Update and Delete, and a Tx stays usable |
TestUUIDPreconditionIsRefusedBeforeAnyStatement |
A text precondition with a NUL character or invalid UTF-8, as a scalar, LIKE or list operand, is refused before SQL on both storage kinds: no table changes, and a Tx keeps its earlier write and commits a later one |
TestTextPreconditionIsRefusedBeforeAnyStatement |
Numeric preconditions agree with exact arithmetic on every operator and operand class (fractions, both signs, beyond the column's width, around 2^53 and the int64 limits) over smallint, integer, bigint and double columns and EAV with DOUBLE PRECISION and NUMERIC value_numeric, through Update, Delete, Tx.Update and Tx.Delete; a refusal writes nothing and a Tx that handles it commits a later write |
TestPreconditionNumericOperandsMatchArithmetic, TestPreconditionOperandsReproductions |
starts_with / contains preconditions match their operand literally |
TestPreconditionLikeOperandsAreLiteral |
NaN, Inf and a bigint operand beyond int64 are refused before SQL on every route, and a Tx stays usable |
TestNonFinitePreconditionIsRefusedBeforeAnyStatement |
A missing or deleted row is ErrNotFound |
TestMissingRowIsNotFoundWhateverThePrecondition |
| Two writers with one expected version: one commits (60 iterations, both queued on the lock) | TestSameExpectedVersionAdmitsOneWriter_PostgresOnly |
| 32 concurrent increments total exactly | TestConcurrentIncrementsLoseNothing |
| Read-then-conditional-write retry loop loses no update | TestVersionRetryLoopLosesNoUpdate |
server_time equals the transaction's now() |
TestServerTimeIsTheTransactionInstant |
server_time and $now share one instant in a Tx |
TestNowPreconditionAndServerTimeShareTheTxInstant |
A failed precondition rolls back a Tx that returns it, and does not poison one that handles it |
TestConditionalUpdateFailureRollsBackTheTx, TestSwallowedPreconditionFailureDoesNotPoisonTheTx |