-
Notifications
You must be signed in to change notification settings - Fork 265
feat: then_concurrent() for Promise API #1364
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Please feel free to suggest different naming or generally a different API for this feature. This is just the first thing that came to mind on how to support the feature I need. |
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1364 +/- ##
==========================================
+ Coverage 80.38% 80.62% +0.24%
==========================================
Files 104 104
Lines 15272 15443 +171
==========================================
+ Hits 12276 12451 +175
+ Misses 2996 2992 -4 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
near-sdk/src/promise.rs
Outdated
/// let p4 = Promise::new("eva_near".parse().unwrap()).create_account(); | ||
/// p1.then(p2).then_many(&[&p3, &p4]); | ||
/// ``` | ||
pub fn then_many(self, others: &[&Promise]) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks like the method should return a Vec<Promise>
of modified others
, shouldn't it?
So that individual elements of others
can be used to chain promise calls further.
And to have some kind description of what type a contract call, doing then_many
, returns, if Promise
ever becomes a Promise<T>
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would chaining with a Vec look? For single promises, it makes sense but not sure how it works for a group.
Instead, I thought we just borrow them, leaving the ownership at the caller. This makes it possible to reuse p3
and p4
in the doc comment.
And to have some kind description of what type a contract call, doing then_many, returns, if Promise ever becomes a Promise .
then_many
doesn't create a new promise, it just creates dependencies between existing promises.
So, it doesn't have a result that could be used to return from a smart contract.
In the doc comment, you can return either p3
or p4
. But not both at the same time. And their type is unaffected by then_many
.
Does that make sense @dj8yfo ? Do you have a suggestion on how to make it more intuitive?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe I should also give some context on how I want to use it.
This is for a sharded FT design of ft_transfer_call
, as outlined in this comment.
And this is how I use it in the reference implementation:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jakmeier The proposed interface for then_many
is error-prone in my opinion.
In the case of then
and and
, the self
and other
are consumed and a modified other
is returned. In your doc string example:
/// ```no_run
/// # use near_sdk::Promise;
/// let p1 = Promise::new("bob_near".parse().unwrap()).create_account();
/// let p2 = Promise::new("carol_near".parse().unwrap()).create_account();
/// let p3 = Promise::new("dave_near".parse().unwrap()).create_account();
/// let p4 = Promise::new("eva_near".parse().unwrap()).create_account();
/// p1.then(p2).then_many(&[&p3, &p4]);
/// ```
p1
and p2
are consumed, but p3
and p4
are left for potential misuse (e.g. chain again with something else. It is not C++ world and we'd love to save devs from potential footguns.
In your reference implementation you want to return one of the final promises (deposit
) as the transaction output value, but you don't want and cannot return results of two promises (well, you may join them in a reducing promise). Your intent sounds like "make these calls concurrently and detached", and make this one specific call the main execution branch. We brainstormed it today and originally thought of .then_many(other: Promise, detached: Vec<Promise>) -> Promise
(which will return the modified other
, similarly to .then()
implementation), but this interface looks odd to me, and while I was typing I came to this realization that we kind of need the following:
/// ```no_run
/// # use near_sdk::Promise;
/// let p1 = Promise::new("bob_near".parse().unwrap()).create_account();
/// let p2 = Promise::new("carol_near".parse().unwrap()).create_account();
/// let p3 = Promise::new("dave_near".parse().unwrap()).create_account();
/// let p4 = Promise::new("eva_near".parse().unwrap()).create_account();
/// p1.then(p2).and_detached(vec![p3, p4]);
/// ```
where and_detached
will return p2
and consume p3
and p4
, or for your use-case, where you want deposit
to be returned:
let deposit = on_transfer.then(deposit).and_detached(vec![refund]);
// The final value returned should be how much was transferred, to be
// in line with NEP-141.
deposit.into()
or you can even chain it all into a single line:
on_transfer.then(deposit).and_detached(vec![refund]).into()
What do you think? cc @dj8yfo
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at it again, I think it won't be clear that refund will receive the output from on_transfer
and not from deposit
, so maybe this one is better:
p1.then(p2).then_detached(vec![p3, p4]);
on_transfer.then_detached(vec![refund]).then(deposit).into()
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Your intent sounds like "make these calls concurrently and detached", and make this one specific call the main execution branch.
This might be nitpicking but I'm thinking of it much more as "send the same data output to both these receipts". In your proposed designs, it's not very intuitive, at least for me, that deposit
and refund
are guaranteed to have the exact same callback inputs. Which is crucial for security.
This is clear:
p1.then(p2).then_detached(vec![p3, p4]);
But this isn't:
on_transfer.then_detached(vec![refund]).then(deposit).into()
@dj8yfo @frol What would you think of an interface like this?
pub fn then_concurrent(self, others: Vec<Promise>) -> ThenConcurrent;
struct ThenConcurrent {
inner: Vec<Promise>,
}
impl ThenConcurrent {
fn join(self) -> PromiseOrValue<T> {
// note: this syntax doesn't work, we needs to `and` them pairwise
Promise::and(self.inner)
}
fn return_by_index<T>(self, index: usize) -> PromiseOrValue<T> {
// note: might want to use `get` and return Option here to prevent out of boundary access panic
self.inner[index].into()
}
}
Then we can allow chaining or returning but also protect from accidental misuse.
// this returns the result of p3 as the outcome of the current execution
p1.then(p2).then_concurrent(vec![p3, p4]).return_by_index(0);
// this wraps `p3` and `p4` with `and`, then executes `p5`
p1.then(p2).then_concurrent(vec![p3, p4]).join().then(p5);
// maybe we also can add more flexible ways of joining
// this wraps `p3` and `p4` with `and`, then executes `p5`
p1.then(p2).then_concurrent(vec![p3, p4, p5]).join_some(&[true, true, false]).then(p5);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jakmeier I like the direction of thought, especially the join() idea. I don't like that much the indexing idea, but that is not terrible. Yet, what if I want to run p3 and p4 concurrently, but return p2 as the result, so p3 & p4 are kind of refunds. It is still possible to achieve that with a low-level as_return()
API, but I think it is not widely known, and not easy to discover (though we may highlight the use of it in the then_concurrent()
doc-string example:
p1.then(p2).as_return().then_concurrent(vec![p3, p4]);
Instead of join_some
we may also think about joining them before the then_concurrent
call:
let p3 = Promise...;
let p4 = Promise...;
let p3_and_p4 = p3.and(p4); // p3 and p4 are consumed into this new chain, and p3_and_p4 holds a new Promise with a Joint subtype with p3 and p4
p1.then(p2).then_concurrent(vec![p3_and_p4, p5]).return_by_index(0).then(p6);
Yet, for this to work, then_concurrent
would need to recursively go down into the promises and find the leafs (where after
is set to None), and that might be not intuitive, which is why it is forbidden in then
implementation:
let p1 = Promise...;
let p2 = Promise...;
let p3 = Promise...;
let p2_then_p3 = p2.then(p3);
p1.then(p2_then_p3);
// ^^^ this technically could work and build the chain of p1 -> p2 -> p3,
// but the current implementation of `then` forbids it as it might be
// confusing as `p2.then(p3)` returns `p3` and so `p1.then(...)` is ambiguous
// whether the intent was to call `p3` after `p1` as well as after `p2` (so "joined"(p1 -> p2) -> p3)
// or just p1 -> p2 -> p3. The order is the same, but the number of inputs to p3 will be different.
Also, I don't think there is a value in returning PromiseOrValue from return_by_index()
, as my contract functions may just need to return a Promise.
I don't find return_by_index
clear enough, so I tried to find an alternative with conserns in the comments:
then_concurrent([]).take_by_index(0).then(p6)
then_concurrent([]).join_by_index(0).then(p6)
(there is technically nothing to "join")then_concurrent([]).join_one(0).then(p6)
then_concurrent([]).join_first().then(p6)
("first" is ambiguous - first one that completes or first from the list before)then_concurrent([]).after(0).then(p6)
then_concurrent([]).wait(0).then(p6)
(we are not "waiting" in the execution flow)then_concurrent([]).then(0, p6)
(ugly and does not solve the case when I don't want tothen
and just want to pick one of the promises as the return value)then_concurrent([]).next(0).then(p6)
then_concurrent([]).next_is(0).then(p6)
then_concurrent([]).chain_after(0).then(p6)
then_concurrent([]).first().then(p6)
then_concurrent([]).wait_first().then(p6)
then_concurrent([]).detach_all_except(0).then(p6)
(ugly name, but this is my runner-up by clarity)then_concurrent([]).attach(0).then(p6)
then_concurrent([]).select(0).then(p6)
I still hope there is a better name, but I couldn't find it :-(
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@frol These are some good inputs.
You are right! My use case can also be done with as_return
.
Then maybe let's consider if we are overengineering it here. For most use cases, then_concurrent()
+ join()
is probably enough, isn't it? And for extra flexibility, even if awkward in chaining, we could add to_vec()
that just returns the full vector of promises back from a JoinConcurrent
struct.
On the other hand, if we want to provide a more complete API around concurrent promises, I'm starting to think we should take inspiration of the Rust std::vec::Vec
API design for methods on ThenConcurrent
.
My current favorite approach is with just copying the design of split_off
. Basically, we define fn split_off(this: &mut ThenConcurrent) -> ThenConcurrent
and it can be useful like so:
// p1 -> (p2, p3, p4 , p5)
// (p4, p5) -> p6
p1.then_concurrent(vec![p2, p3, p4, p5]).split_off(2).then(p6)
Or more generally:
let mut fan_out: ThenConcurrent = p1.then_concurrent(vec![p2, p3, p4, p5]);
let p4_and_p5: ThenConcurrent = fan_out.split_off(2);
// fan_out now contains only p2 and p3
fan_out.join()...
p4_and_p5.join()...
My original use case would then look as follows.
// executes respecting on_transfer -> (refund, deposit)
// returns deposit as PromiseOrValue type
on_transfer.then_concurrent(vec![refund, deposit]).split_off(1).join().into()
Here I use that join
on a ThenConcurrent
with a single promise just returns that single promise. (the recursion base case)
Note that I also had to swap the order of refund
and deposit
for this to work in a chained syntax. But since the order in concurrent promises doesn't matter, we can always freely arrange those promises to best fit split_off
able to divide into desired groups.
Also, I don't think there is a value in returning PromiseOrValue from return_by_index(), as my contract functions may just need to return a Promise.
I think you are right, I had misplaced concerns about returning it as a Promise
when I wrote that last comment.
I still hope there is a better name, but I couldn't find it :-(
What do you think about split_off
? It's not super clear IMO but at least fits the Rust world.
Also, how are you feeling about then_concurrent
? I'm not entirely sold on that either.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like then_concurrent
. I like how we turned it all into join
. I'm not a huge fan of split_off
, but I think it is a reasonable trade-off. Will you have the capacity to implement it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@frol Yes, I just pushed another commit that implements everything as discussed. PTAL when you have a moment.
Send the same outgoing data dependency to many function calls that run after. This has always been possible with the low-level API but the Promise API was not expressive enough. Specifically, in `near_sys::promise_then`, one can use the same promise index more than once. https://github.com/near/near-sdk-rs/blob/985c16b8fffc623096d0b7e60b26746842a2d712/near-sys/src/lib.rs#L77-L87 However, using the SDK's promise API, [`Promise::then`](https://docs.rs/near-sdk/latest/near_sdk/struct.Promise.html#method.then) consumes the promise, which makes calling `then` twice impossible. I suggest to add a method `then_many` to allow more complex dependencies.
and renaming to `then_concurrent`
It doesn't work to derive, as compilation fails. Thinking again, I don't see why this would be necessary or useful to have anyway.
This should now be ready for a review. I've implemented everything as discussed. For Tests cover the basic functionality of Caveat: I'm not entirely familiar with how the |
previously failed with: 501 | /// The returned [`ConcurrentPromises`] allows chaining more promises. | ^^^^^^^^^^^^^^^^^^ this item is private
near-sdk/src/promise.rs
Outdated
pub fn then_concurrent(self, promises: Vec<Promise>) -> ConcurrentPromises { | ||
let mapped_promises = promises | ||
.into_iter() | ||
.map(|other| { | ||
let self_clone = Promise { | ||
subtype: self.subtype.clone(), | ||
// Cloning `should_return` is okay, since it cannot be modified | ||
// again later. (`self` is consumed ) | ||
should_return: self.should_return.clone(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am concerted about it as Promise
implements Drop:
near-sdk-rs/near-sdk/src/promise.rs
Lines 576 to 580 in cffda5c
impl Drop for Promise { | |
fn drop(&mut self) { | |
self.construct_recursively(); | |
} | |
} |
which calls construct_recursively
:
near-sdk-rs/near-sdk/src/promise.rs
Lines 564 to 574 in cffda5c
fn construct_recursively(&self) -> PromiseIndex { | |
let res = match &self.subtype { | |
PromiseSubtype::Single(x) => x.construct_recursively(), | |
PromiseSubtype::Joint(x) => x.construct_recursively(), | |
}; | |
if *self.should_return.borrow() { | |
crate::env::promise_return(res); | |
} | |
res | |
} | |
} |
which calls construct_recursively
on PromiseSingle, which calls the host functions to register a promise:
near-sdk-rs/near-sdk/src/promise.rs
Lines 150 to 154 in cffda5c
let promise_index = if let Some(after) = self.after.borrow().as_ref() { | |
crate::env::promise_batch_then(after.construct_recursively(), &self.account_id) | |
} else { | |
crate::env::promise_batch_create(&self.account_id) | |
}; |
Thus, cloning self will result in 1+N calls to the self
promise it seems like.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True, thanks for catching that!
I've fixed that by introducing reference counting on the after
promise. This seems cleaner to me. It preserves how promises work prior to this PR, having exactly one Promise
per index and only one drop
call as a consequence.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jakmeier Please ignore the CI errors related to schemars version mismatch. We released near-account-id with a wider range of allowed versions for schemars and it does not get properly selected (cargo picks up a mix of 0.8 and 1.0 versions instead of sticking to 0.8)
Use an Rc to share the promise in `after` to allow multiple promises to depend on the same previous promise, without creating a new promise. This avoids `construct_recursively` to be called when the promise is dropped.
This is not necessary and could become a footgun.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@race-of-sloths score 13
@jakmeier Thank you for your contribution! Your pull request is now a part of the Race of Sloths! Current status: executed
Your contribution is much appreciated with a final score of 13! @frol received 25 Sloth Points for reviewing and scoring this pull request. Congratulations @jakmeier! Your PR was highly scored and you completed another monthly streak! To keep your monthly streak make another pull request next month and get 8+ score for it What is the Race of SlothsRace of Sloths is a friendly competition where you can participate in challenges and compete with other open-source contributors within your normal workflow For contributors:
For maintainers:
Feel free to check our website for additional details! Bot commands
|
Send the same outgoing data dependency to many function calls that run after.
This has always been possible with the low-level API but the Promise API was not expressive enough.
Specifically, in
near_sys::promise_then
, one can use the same promise index more than once.near-sdk-rs/near-sys/src/lib.rs
Lines 77 to 87 in 985c16b
However, using the SDK's promise API,
Promise::then
consumes the promise, which makes callingthen
twice impossible.This adds method
then_concurrent
to allow more complex dependencies. This method returnsConcurrentPromises
that can be joined and chained again.See the included doc comments and tests for examples.