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.versionHere 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 runThe 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
}
}