Async fn in trait... not
This article is part of the Updating fasterthanli.me for 2022 series.
- Async fn in trait... not
Contents
Async fn in trait... not
I was planning on showing the in-progress async_fn_in_trait
feature in the
context of my website, but it turns out, I can't!
My website uses two databases: one local SQLite database for content, and a shared Postgres database for user credentials, preferences etc. Migrations are run on startup, and each migration implements one of the following traits:
#[async_trait::async_trait] pub trait MigrationSqlite { fn tag(&self) -> &'static str; async fn up(&self, db: &mut Transaction<Sqlite>) -> Result<()>; } #[async_trait::async_trait] pub trait MigrationPostgres { fn tag(&self) -> &'static str; async fn up(&self, db: &mut Transaction<Postgres>) -> Result<()>; }
...which can have async
functions thanks to the
async-trait crate.
Enabling the unstable feature works for the trait definition:
#![allow(incomplete_features)] #![feature(async_fn_in_trait)] pub trait MigrationPostgres { fn tag(&self) -> &'static str; async fn up(&self, db: &mut Transaction<Postgres>) -> Result<()>; }
...but fails later, because I'm making a Vec
of migrations, to pass to:
pub async fn migrate_all_postgres( db: &PgPool, wanted_list: Vec<Box<dyn MigrationPostgres>>, ) -> Result<()> { // (cut) }
...and traits with async fns (the real thing) aren't trait-object-safe for now:
$ cargo check Checking futile-db v0.1.0 (/home/amos/bearcove/futile/crates/futile-db) (cut) error[E0038]: the trait `MigrationPostgres` cannot be made into an object --> crates/futile-db/src/lib.rs:64:47 | 64 | async fn mk_postgres_pool(migrations: Vec<Box<dyn MigrationPostgres>>) -> Result<sqlx::PgPool> { | ^^^^^^^^^^^^^^^^^^^^^ `MigrationPostgres` cannot be made into an object | note: for a trait to be "object safe" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety> --> crates/futile-db/src/migrations/mod.rs:135:14 | 133 | pub trait MigrationPostgres { | ----------------- this trait cannot be made into an object... 134 | fn tag(&self) -> &'static str; 135 | async fn up(&self, db: &mut Transaction<Postgres>) -> Result<()>; | ^^ ...because method `up` references an `impl Trait` type in its return type = help: consider moving `up` to another trait error[E0038]: the trait `MigrationPostgres` cannot be made into an object --> crates/futile-db/src/migrations/mod.rs:71:26 | 71 | wanted_list: Vec<Box<dyn MigrationPostgres>>, | ^^^^^^^^^^^^^^^^^^^^^ `MigrationPostgres` cannot be made into an object | note: for a trait to be "object safe" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety> --> crates/futile-db/src/migrations/mod.rs:135:14 | 133 | pub trait MigrationPostgres { | ----------------- this trait cannot be made into an object... 134 | fn tag(&self) -> &'static str; 135 | async fn up(&self, db: &mut Transaction<Postgres>) -> Result<()>; | ^^ ...because method `up` references an `impl Trait` type in its return type = help: consider moving `up` to another trait (cut)
There is a very good reason for that limitation, and I hear, to lift it,
something like dyn*
(which is very hard to search for) is needed.
So, instead, I'll show them what I did for
hring, to get an "owned" version of the
AsyncRead
/ AsyncWrite
traits, that is io-uring-friendly:
pub trait ReadOwned { async fn read<B: IoBufMut>(&self, buf: B) -> BufResult<usize, B>; } pub trait WriteOwned { async fn write<B: IoBuf>(&self, buf: B) -> BufResult<usize, B>; async fn writev<B: IoBuf>(&self, list: Vec<B>) -> BufResult<usize, Vec<B>> { let mut out_list = Vec::with_capacity(list.len()); let mut list = list.into_iter(); let mut total = 0; while let Some(buf) = list.next() { let (res, buf) = self.write(buf).await; out_list.push(buf); match res { Ok(n) => total += n, Err(e) => { out_list.extend(list); return (Err(e), out_list); } } } (Ok(total), out_list) } async fn write_all<B: IoBuf>(&self, mut buf: B) -> BufResult<(), B> { let mut written = 0; let len = buf.bytes_init(); while written < len { let (res, slice) = self.write(buf.slice(written..len)).await; buf = slice.into_inner(); let n = match res { Ok(n) => n, Err(e) => return (Err(e), buf), }; written += n; } (Ok(()), buf) } } pub trait ReadWriteOwned: ReadOwned + WriteOwned {} impl<T> ReadWriteOwned for T where T: ReadOwned + WriteOwned {}
This all works flawlessly. Here's the implementation for
tokio-uring's TcpStream
type:
impl ReadOwned for TcpStream { async fn read<B: IoBufMut>(&self, buf: B) -> BufResult<usize, B> { TcpStream::read(self, buf).await } } impl WriteOwned for TcpStream { async fn write<B: IoBuf>(&self, buf: B) -> BufResult<usize, B> { TcpStream::write(self, buf).await } async fn writev<B: IoBuf>(&self, list: Vec<B>) -> BufResult<usize, Vec<B>> { TcpStream::writev(self, list).await } }
(It's trivial, since the trait is closely modelled after tokio-uring's own types).
Object trait safety is still an issue here, but we can work around it by making
most functions take an &impl ReadOwned
instead of a &dyn ReadOwned
, which
makes the function generic (which means it'll be monomorphized, which in turns
means the binary will be larger, but there won't be dynamic dispatch at all):
/// Returns `None` on EOF, error if partially parsed message. pub(crate) async fn read_and_parse<Parser, Output>( parser: Parser, // 👇 stream: &impl ReadOwned, mut buf: RollMut, max_len: usize, ) -> eyre::Result<Option<(RollMut, Output)>> where Parser: Fn(Roll) -> IResult<Roll, Output>, { // cut }
The rest of the hring API is carefully designed to work around that limitation for now, which is... a bit of a balancing act, but it works!
This article is part 3 of the Updating fasterthanli.me for 2022 series.
If you liked what you saw, please support my work!