Skip to main content

How we made SeaORM synchronous

· 7 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

SeaORM began as Rust's first async‑first ORM. Now we've come full circle with a synchronous crate: perfect for building lightweight CLI programs with SQLite.

In this post, we'll share how we ported a complex library like SeaORM, the tricks we learnt along the way, and the steps we're taking to keep it maintainable for the long run.

Gist

We took an approach of translation: we wrote a script to convert async functions into synchronous ones. (It's more than a simple find‑and‑replace.)

The script would read the src directory and rewrite that into a new crate sea-orm-sync. This crate isn't a fork: it will be continually rebased on sea-orm, inheriting all new features and bug fixes.

sea-orm-sync supports the entire SeaORM's API surface, including recent features like Entity Loader, Nested ActiveModel and Entity-First workflow.

Async -> Sync

At a high level, async Rust can be seen as a more complex form of sync Rust. So converting an async program is possible by stripping out the runtime and removing all async / await usage.

However, you can't always go from sync to async. Async Rust tightens lifetime rules and introduces Send / Sync requirements for futures and async closures, so existing sync code may fail those constraints.

Now, let's go from all the necessary conversions, in order of complexity:

1. async / await

Removing all async and .await keywords will almost make it compile.

2. #[main] / #[test]

Simply remove tokio / async-std from Cargo and remove #[tokio::main]. Then replace #[tokio::test] with #[test].

3. async_trait

Simply remove #[async_trait] usage and it's Cargo dependency.

4. Send + Sync

Most Send and Sync in trait bounds can be removed.

5. BoxFuture

If you've used BoxFuture you can use the following shim:

#[cfg(not(feature = "sync"))]
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;

#[cfg(feature = "sync")]
type BoxFuture<'a, T> = T;

6. Box::pin(async move { .. })

Function signature: FnOnce() -> Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'c>> can simply be FnOnce() -> Result<T, E>.

async
async fn transaction<F, T, E>(&self, _callback: F) -> Result<T, TransactionError<E>>
where
F: for<'c> FnOnce(
&'c DatabaseTransaction,
) -> Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'c>>
+ Send,
T: Send,
E: std::fmt::Display + std::fmt::Debug + Send,
{}
sync
fn transaction<F, T, E>(&self, _callback: F) -> Result<T, TransactionError<E>>
where
F: for<'c> FnOnce(&'c DatabaseTransaction) -> Result<T, E>,
E: std::fmt::Display + std::fmt::Debug,
{}

Usage: Async futures can be simply converted to {}.

async
db
.transaction::<_, _, DbErr>(|txn| {
Box::pin(async move {
let bakeries = Bakery::find()
.filter(bakery::Column::Name.contains("Bakery"))
.all(txn)
.await?;

Ok(())
})
})
.await?
sync
db
.transaction::<_, _, DbErr>(|txn| {
let bakeries = Bakery::find()
.filter(bakery::Column::Name.contains("Bakery"))
.all(txn)?;

Ok(())
})?

7. Mutex

The semantic difference in lock() between a synchronous mutex (std::sync::Mutex) and an asynchronous mutex (tokio::sync::Mutex or async_std::sync::Mutex) is crucial.

std::sync::Mutex::lock()

fn lock(&self) -> LockResult<MutexGuard<T>>
  • Fallible: Returns a Result because the lock can be poisoned.

  • Poisoning happens if a thread panics while holding the lock.

tokio::sync::Mutex::lock().await

async fn lock(&self) -> MutexGuard<'_, T>
  • Infallible: always succeeds and returns a guard

  • In async world mutexes don't get poisoned. A panic inside a task would abort the task, but would not affect other tasks. This is actually a problem in async Rust as a task can fail silently

In practice, we did:

#[cfg(not(feature = "sync"))]
let conn = *self.conn.lock().await;

#[cfg(feature = "sync")]
let conn = *self.conn.lock().map_err(|_| DbErr::MutexPoisonError)?;

8. Stream

This is the biggest discrepency between sync and async Rust. Simply put, Stream is the async version of Iterator:

IteratorStream
DefinitionSynchronous iteratorAsynchronous iterator
Trait methodfn next(&mut self) -> Option<Item>fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Item>>
ConsumptionCall .next() repeatedlyCall .next().await repeatedly (via StreamExt)
Blocking vs yieldingProduces items immediately, blocks until readyProduces items asynchronously, yields if not ready
Use casesIterating over collectionsReading database rows
Usagefor x in iter { ... }while let Some(x) = stream.next().await { ... }

Stream occurs in many places throughout the SeaORM API. The Stream trait is replaced by the Iterator trait.

Box<dyn Stream>

#[cfg(not(feature = "sync"))]
type PinBoxStream<'a> = Pin<Box<dyn Stream<Item = Result<QueryResult, DbErr>> + 'a + Send>>;

#[cfg(feature = "sync")]
type PinBoxStream<'a> = Box<dyn Iterator<Item = Result<QueryResult, DbErr>> + 'a>;

impl Stream bounds

async
async fn stream<'a: 'b, 'b, C>(self, db: &'a C)
-> Result<impl Stream<Item = Result<E::Model, DbErr>> + 'b + Send, DbErr>
where
C: ConnectionTrait + StreamTrait + Send,
{}
sync
fn stream<'a: 'b, 'b, C>(self, db: &'a C)
-> Result<impl Iterator<Item = Result<E::Model, DbErr>> + 'b, DbErr>
where
C: ConnectionTrait + StreamTrait,
{}

impl Stream for

#[cfg(not(feature = "sync"))]
impl Stream for TransactionStream<'_> {
type Item = Result<QueryResult, DbErr>;

fn poll_next(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<Option<Self::Item>> {
Pin::new(self.stream).poll_next(cx)
}
}

#[cfg(feature = "sync")]
impl Iterator for TransactionStream<'_> {
type Item = Result<QueryResult, DbErr>;

fn next(&mut self) -> Option<Self::Item> {
self.stream.next()
}
}

TryStreamExt

There is no equivalent to TryStreamExt in Rust's standard library, luckily it's very easy to make a shim:

while let Some(item) = stream.try_next().await? {
let item: fruit::ActiveModel = item.into();
}
pub trait TryIterator<T, E> {
fn try_next(&mut self) -> Result<Option<T>, E>;
}

impl<I, T, E> TryIterator<T, E> for I
where
I: Iterator<Item = Result<T, E>>,
{
fn try_next(&mut self) -> Result<Option<T>, E> {
self.next().transpose() // Option<Result<T>> becomes Result<Option<T>>
}
}

9. File / Network I/O

This is very application specific. In SeaORM's case, the external I/O is handled by rusqlite and sqlx respectively. Their APIs differ significantly, that's why we have written sea-query-sqlx and sea-query-rusqlite to align them.

For HTTP requests, you can simply use the sync and async versions of Client in different contexts.

For file I/O, the API difference between sync and async Rust is very small.

Conclusion: SQLite + SeaORM Sync = ⚡

You can now use sea-orm-sync in CLI programs, and only bringing in small number of additional dependencies compared to having to bring in the async ecosystem.

In fact, the compilation time speaks for itself. The async version of quickstart took 30 seconds to compile, while the sync version only took 15 seconds!

Right now only rusqlite is supported, but SeaORM's entire API surface is available. It's a breeze to add SQLite query capabilities to CLI programs where async would be overkill.

let db = &sea_orm::Database::connect("sqlite::memory:")?;

// Setup the database: create tables
db.get_schema_registry("sea_orm_quickstart::*").sync(db)?;

info!("Create user Bob with a profile:");
let bob = user::ActiveModel::builder()
.set_name("Bob")
.set_email("bob@sea-ql.org")
.set_profile(profile::ActiveModel::builder().set_picture("Tennis"))
.insert(db)?;

info!("Query user with profile in a single query:");
let bob = user::Entity::load()
.filter_by_id(bob.id)
.with(profile::Entity)
.one(db)?
.expect("Not found");
assert_eq!(bob.name, "Bob");
assert_eq!(bob.profile.as_ref().unwrap().picture, "Tennis");

SeaORM 2.0 RC

SeaORM 2.0 is shaping up to be our most significant release yet - with a few breaking changes, plenty of enhancements, and a clear focus on developer experience.

SeaORM 2.0 has reached its release candidate phase. We'd love for you to try it out and help shape the final release by sharing your feedback.

🦀 Rustacean Sticker Pack

The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.

Sticker Pack Contents:

  • Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
  • Mascots: Ferris the Crab x 3, Terres the Hermit Crab
  • The Rustacean wordmark

Support SeaQL and get a Sticker Pack!

Rustacean Sticker Pack by SeaQL