Star.mo - Dealing with async* - A Library

https://mops.one/star

Star Motoko Library - star.mo

A Motoko library for handling asynchronous and trappable behavior with the async* functions.

Overview

Star is a custom type with three variants: #trappable, #awaited, and #err. These represent different states of success or failure. #trappable and #awaited represent success, while #err represents error. The difference between #trappable and #awaited is that #awaited is produced with an awaited call, while #trappable is produced without one.

This distinction is important because a value returned from an async* function carries no state information about whether the called function made a state commitment or not. You will not know if the logic before your call has been committed to the state tree or not.

The suggested pattern is to never use async* without returning a Star and handling the four possible states:

  • an await occured and you have a return value #awaited(X)

  • an await did not occur and you have a return value #trappable(X)

  • an error occured but state was committed. #err(#awaited(E))

  • an error occured but state was not committed. #err(#trappable(E))

Usage

To use this library in your project, you will need to import it first.

mops install star


import Star "mo:star/star";

Requires at least moc 0.8.3.

Then, you can use the provided functions to work with Star types:


equal

compare

flatten

mapOk

mapErr

fromOption

toOption

toResult

fromResult

iterate

isOk

isAwaited

isTrappable

isErr

assertOk

assertErr

assertTrappable

assertAwaited

Example

Here’s an example of using a Star type with a function createUser(user : User) : Star<Id, String>:


switch(await* createUser(myUser)) {

case (#awaited(id)) { Debug.print("Created new user with id and state committed: " # id) };

case (#trappable(id)) { Debug.print("Created new user with id and state has not been committed: " # id) };

case (#err(#awaited(msg))) { Debug.print("Failed to create user with the error but state was committed: " # msg) };

case (#err(#trappable(msg))) { Debug.print("Failed to create user with the error but state was not committed: " # msg) };

}

License

This library is provided under the MIT License.

Contributing

Please feel free to open issues or submit pull requests for any bug fixes or improvements.

5 Likes

I find the names of the variant cases not intuitive. If I understand correctly, you want to communicate to the calling code whether a commit point happened inside the async* function or not so that the calling code knows whether everything that it did before the await* is committed or not. So it is really about “committed” or not. If the calling code interprets that as “trappable” or something else is up to the calling code. Hence, I find that word unintuitive.

Anyway, there is something else I wanted to point out in the context of trying to make async* safe. Your point is that the calling code wants to know what happened inside the async* function. But there is also the other direction. There are cases when the code inside the async* function relies on the calling code behaving in a certain way. For example, if the async* functions handles a lock, say the async* function does an await and after that releases a lock, then the calling code should not trap after the await* statement because otherwise the lock release would be rolled back.

In general, of course, we could say that that is not a dynamic situation. That is, if an async* function does that then its documentation should be clear about that the calling code is never allowed to trap after await*-ing it (before the next commit point). But there could be more complicated situations where the async* function’s internal code wants to communicate dynamically to the calling code whether the calling code is allowed to trap or not. So I think that may be an orthogonal piece of information that a library like yours could handle.

1 Like

We can certainly change the names if you think they are confusing. My assumptions were:

#awaited → indicate that an actual await had occurred
#trappable → you can still trap without any state having been committed.

I think I get what you are saying about lock handling, but doesn’t it handle that? If you have an await and the lock is handled you’d return back #awaited and know you can’t trap. Maybe you mean that if the async* doesn’t await but does handle an unlock(that was set by another function)? In that case, your function would have a trappable and know that it should return a Result#Error that would commit the unlock?

I think in making this library that my assumption was that you should not ever trap after an async* that returns you an #awaited. You are stuck with consequences at that point.

Maybe I misinterpreted your use case? Maybe some peudo code would help?

I’m happy to change the variant names if you have better suggestions!