I have an application that lets you register async closures to be run at a later time (at commit or rollback in my case, but that doesn't matter).
With the stabilization of async closures, I tried to make this a bit more ergonomic. As far as I understand, I need to use a trait object to "erase the type" of the closure, and that cannot be done with the AsyncFn* traits, since they aren't dyn-safe.
I arrived at this solution:
This allows the user to use an async closure without manual boxing. I am not very concerned about backwards compatibility since I control the codebase, but this also has the benefit of being backwards compatible (my previous version did require the user to box the future returned by the closure, so it now incurs in double boxing; but I can easily fix that later on).
Is the way I'm doing it sensible? Are there better approaches? (Doesn't matter if this breaks compatibility with my previous version)
You might consider not storing callbacks at all, if it makes sense for your API. The async-await pattern is one way to escape callback hell (as it is called in some other languages). It seems that the callbacks are only executed once (using FnOnce).
Perhaps the following approach makes sense:
match my_api.commit().await {
Ok(_) => println!("yay"),
Err(Error::Rollback) => eprintln!("rollback"),
}
To piggyback on this, oneshot::Receiver<()> has two states when it has been awaited (success or the sender dropped) so it might make more sense to just store a vector of senders. Using channels means the user can do things such as select on or join the outcome of multiple operations, etc.
Sorry if my last remark about double boxing was unclear. I meant that my previous iteration required boxing in the "push" site (look at lines 40-44 in my snippet), but I've now realized that I can box it inside push, so just upgrading the library without fixing the calls to push would have one extra boxing.
I didn't know about stackfuture, thanks.
I know they'll come out with the best solution
Storing the future, also very interesting.
Would this also work if the closures had arguments (for instance, whether the transaction committed or rolled back)?
I'd tend agree with you, but we use this in the case that we don't know which code will be needed at commit/rollback, it depends a lot on the command being processed. And for us it makes more sense to have the code that reacts to the transaction result together with the logic code.
We don't get too much callback hell since those callbacks are limited to update a log record (say, to mark that change as being rolled back), or send an "undo" signal to another service to cancel a distributed transaction. Of our 40-ish services, only 3 or 4 use this sistem.
Could you elaborate on this?
To see if I'm understanding correctly, the "logic code" would push a sender. Then, when the commit or rollback happens, the "central code" handling that sends a message thought all the senders so they can react to this change?
In fact, this is quite interesting because this would also work on panic, right? The receiver would get closed without the transaction result message and would be able to react to that. We try our best to never panic,... but stuff happens.
Actors in tokio is a must-read. Basically instead of storing a vector of callbacks, store a vector of oneshot::Sender<()>. If the transaction fails, drop the sender. If it succeeds send (). If you need callback-like behavior you spawn a task that waits on the associated receiver. But you can do so much more with the receivers!