Skip to content
Concepts

Optimistic locking

Maya and Ken open the same post. Maya saves her edit first. When Ken saves his older copy, the application should notice rather than silently replacing Maya's work.

Optimistic locking gives each saved version a number. An edit succeeds only if that number still matches the copy the editor read.

Give the post a version

Add one version field to the model:

version UInt @rent.version

Save the copy you read

Pass the loaded post to the update builder. Rent checks its version and increments it when the edit succeeds:

let updated = client
    .update_post_one(&post)
    .body("new text")
    .save_one()
    .await?;

assert_eq!(updated.version, post.version + 1);

The generated SQL includes both the ID and old version in its predicate, and increments the version in the same update. A stale write returns EntityError::OptimisticLock rather than an affected-row count of zero.

Handle conflicts in one place

You do not need a catch-and-retry block around every save. Handlers can keep using .await? and pass errors to a shared application error type. In an Axum API, for example, implement From<EntityError> once and map OptimisticLock to an HTTP 409 Conflict response. The Axum example uses this shared error-conversion pattern.

The frontend can also share one save flow. Keep the editor's draft when a conflict arrives, then offer to load the latest version or merge the changes. Each screen can use that flow without implementing its own error handling.

Suppose Maya and Ken both open version 7. Maya saves first, creating version 8. Ken's save still names version 7, so Rent rejects it. Sending the same request again will fail again. Fetching version 8 and blindly applying Ken's old text could overwrite Maya's work.

Rent detects the conflict. The application decides which changes to keep. A shared helper can reload and recompute automated changes when the operation is safe to repeat, with a bounded number of attempts. Replacing the expected version or using overwrite() is not a safe default for an editor's stale draft.

Use a version from an API request

When only an ID and version are available:

client
    .update_post()
    .one(post_id)
    .expect_version(read_version)
    .body("new text")
    .save()
    .await?;

Choose the right kind of retry

SituationHow to handle it
Ken submits an older copy of a postReturn a conflict through the shared error handler and preserve his draft.
A short transaction hits a deadlock or serialization failureUse Rent's transaction retry helper to rerun the complete operation.
Several requests increment a counterUse an atomic update so each request adds to the current value.

transaction_retry retries transient database conflicts. It does not retry EntityError::OptimisticLock. Reads and calculations that need to be repeated belong inside the transaction closure, so each attempt can work from the data visible to that transaction.

Deletes, overwrites, and counters

delete_post_one(&post) checks the version the same way, so a stale copy cannot delete a row that changed after it was read. overwrite() on a versioned update or delete drops the comparison for a deliberate last-write-wins. An update still advances the version. Filtered updates such as update_post().id_eq(...) never compare a version, because they did not read one, but they do advance it.

The version is per model: declare it where concurrent editors are real, and leave it off rows with one writer. When concurrent writers only add to a list or adjust a counter, an atomic operation avoids the conflict entirely. See atomic updates.