Skip to content
Tutorials

Optimistic concurrency

Maya and Ken both open a post. Maya publishes it while Ken is still editing an older copy. Ken's save should report a conflict, giving him a chance to keep Maya's change.

Add a version field to Post:

version UInt @rent.version

Here original and stale are copies read before the first save. Updating through an entity checks the version it read and increments the stored version:

let published = client
    .update_post_one(&original)
    .published(true)
    .save_one()
    .await?;

assert_eq!(published.version, original.version + 1);

let rejected = client
    .update_post_one(&stale)
    .title("Stale edit")
    .save_one()
    .await;

assert!(matches!(rejected, Err(EntityError::OptimisticLock { .. })));

This assertion demonstrates the conflict. An application can let .await? pass it to a shared error handler that returns a conflict response. A shared frontend save flow can preserve the editor's draft and offer reload or merge choices. You do not need a separate catch block at every save. See centralized conflict handling for that pattern and the distinction from automatic transaction retries.

Imports and bulk edits

An import can use an upsert without leaving an older editor's version token valid:

use generated::post;

let imported = client
    .upsert_post(post::unique::id)
    .id(published.id)
    .title("Updated by import")
    .body("Import content")
    .published(true)
    .author_id(maya.id)
    .save()
    .await?;

assert_eq!(imported.version, published.version + 1);

The insert branch uses the initial version. The conflict branch increments the existing version. Fields marked @rent.immutable keep their existing values on conflict. The upsert itself is unconditional: choose an entity-targeted update when the write must reject stale input.

Filtered updates also increment the version on each matched row:

client
    .update_post()
    .id_eq(imported.id)
    .published(false)
    .save()
    .await?;

let moderated = client
    .post()
    .id_eq(imported.id)
    .only()
    .await?;

assert_eq!(moderated.version, imported.version + 1);

Saving published after the import, or imported after moderation, returns OptimisticLock: both are now older copies of the row.

Deletes and deliberate overwrites

Deleting through an entity checks its version the same way, so a stale copy cannot remove a row someone else has since changed. When a write should win regardless, say so with overwrite(). It drops the comparison but still advances the version, so every other editor's copy becomes stale:

let restored = client
    .update_post_one(&imported)
    .overwrite()
    .title("Restored by an administrator")
    .save_one()
    .await?;

let rejected = client.delete_post_one(&moderated).exec().await;
assert!(matches!(rejected, Err(EntityError::OptimisticLock { .. })));

client.delete_post_one(&restored).exec().await?;

When the version comes from outside the process, such as an HTTP If-Match header, pass it with expect_version(v) on an ID-targeted builder: client.update_post().one(id).expect_version(v). Declare only one version field, using a non-null mutable UInt or BigInt outside the primary key. Version exhaustion rejects the write. It never resets the counter. Raw SQL writers must increment the version as part of their own update statement.

Complete example

Download this project, or copy the files below into an empty directory.

Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.

rent generate
cargo run

The project includes its database setup. Each lesson uses a small, self-contained schema. You can run it without applying the earlier lessons.

Cargo.toml
[package]
name = "rent-lesson-07-optimistic-locking"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-07-optimistic-locking"
path = "main.rs"

[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"

[database]
dialect = "sqlite"
backends = ["sqlite"]
schema.rsl
datasource db {
  provider = "sqlite"
}

model User {
  id    BigInt  @id
  name  String
  posts Post[]

  @@map("users")
}

model Post {
  id        BigInt   @id
  title     String
  body      String
  published Boolean
  author_id BigInt
  version   UInt     @rent.version
  author    User     @relation(fields: [author_id], references: [id])

  @@map("posts")
}
main.rs
use anyhow::{Result, ensure};
use rent::driver::DatabasePool;
use rent::runtime::EntityError;
use rent::sql::Dialect;

#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;

async fn exercise() -> Result<()> {
    let pool = DatabasePool::connect(Dialect::Sqlite, "sqlite::memory:").await?;
    pool.execute("PRAGMA foreign_keys = ON").await?;
    pool.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
        .await?;
    pool.execute("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, published INTEGER NOT NULL, author_id INTEGER NOT NULL REFERENCES users(id), version INTEGER NOT NULL DEFAULT 0)")
        .await?;
    let client = generated::client::Client::new(pool);
    let maya = client.create_user().id(1).name("Maya").save().await?;
    let original = client
        .create_post()
        .id(10)
        .title("Draft")
        .body("First draft")
        .published(false)
        .author_id(maya.id)
        .save()
        .await?;
    let stale = original.clone();

    let published = client
        .update_post_one(&original)
        .title("Published")
        .published(true)
        .save_one()
        .await?;
    ensure!(published.version == original.version + 1);

    let rejected = client.update_post_one(&stale).title("Stale edit").save_one().await;
    ensure!(matches!(rejected, Err(EntityError::OptimisticLock { .. })));
    ensure!(client.post().id_eq(post_id(&published)).only().await?.title == "Published");
    let imported = client
        .upsert_post(generated::post::unique::id)
        .id(published.id)
        .title("Updated by import")
        .body("Import content")
        .published(true)
        .author_id(maya.id)
        .save()
        .await?;

    ensure!(imported.version == published.version + 1);
    ensure!(matches!(
        client.update_post_one(&published).title("Stale after import").save().await,
        Err(EntityError::OptimisticLock { .. })
    ));
    client.update_post().id_eq(imported.id).published(false).save().await?;
    let moderated = client.post().id_eq(imported.id).only().await?;

    ensure!(moderated.version == imported.version + 1);
    ensure!(matches!(
        client.update_post_one(&imported).title("Stale after moderation").save().await,
        Err(EntityError::OptimisticLock { .. })
    ));

    let restored = client
        .update_post_one(&imported)
        .overwrite()
        .title("Restored by an administrator")
        .save_one()
        .await?;
    ensure!(restored.version == moderated.version + 1);

    ensure!(matches!(
        client.delete_post_one(&moderated).exec().await,
        Err(EntityError::OptimisticLock { .. })
    ));
    ensure!(client.delete_post_one(&restored).exec().await? == 1);
    Ok(())
}

const fn post_id(post: &generated::post::Post) -> i64 {
    post.id
}

#[tokio::main]
async fn main() -> Result<()> {
    exercise().await
}

#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn example_runs() -> anyhow::Result<()> {
        super::exercise().await
    }
}