Skip to content

feat(once): Add async LazyCell implementation - #168

Open
mhambre wants to merge 6 commits into
apache:mainfrom
mhambre:feat/async-lazylock
Open

feat(once): Add async LazyCell implementation#168
mhambre wants to merge 6 commits into
apache:mainfrom
mhambre:feat/async-lazylock

Conversation

@mhambre

@mhambre mhambre commented Aug 21, 2026

Copy link
Copy Markdown

Adds support for an asynchronous LazyCell inspired by async-lazy and the APIs provided by std::sync::LazyLock and std::cell::LazyCell.

The main addition to this is fallible initialization through LazyCell::try_force and being runtime agnostic (async-lazy is married to Tokio through sync primitives) by using asyncband's Mutex and OnceCell.

Some design choices:

  • Cancelled futures resume with the next caller rather than restarting the initialization. Restarts can happen via user-defined fallible errors (e.g. a network blip causing a connection to fail). Users get to choose between retryable failures (monadic error returns) and poisoning on failure (initializer panics) through choosing standard methods versus try_ methods.
    • Retry state management is serialized through the internal Mutex. The state stores at most one pinned attempt, which remains available for the next caller after cancellation. An Err removes the completed attempt but retains the FnMut initializer, allowing the next queued caller to start its own attempt. A successful value is stored in the OnceCell.
    • The caveat to resumability is that all initializers must return a Send + 'static future. Send is required because resuming may happen on a different thread, while 'static is required because the stored future may outlive the caller that started it.
    • For fallible initialization, we opt for FnMut() -> Future<Output = Result<T, E>> over AsyncFnOnce because returning an error must leave the initializer available for another attempt. This design choice was made particularly because async programs open the door to IO-based failures outside of the programmer's control, and we want to allow users to handle those failures gracefully. A good example is the OpenDAL implementation, where the HuggingFace API going down temporarily should not permanently prevent canonicalization.
  • Panics in the initializer poison the cell no matter what route the user chooses for initialization.
  • Keeping with the APIs of std::sync::LazyLock and std::cell::LazyCell, get methods are introspective, while force methods actively perform initialization. We also maintain the use of associated functions over methods.
  • Deref traits aren't implemented because you can't await a deref, so all interactions with the LazyCell happen through associated functions.
  • To support arguments supplied to the initializer, we expose _with functions that take a single argument. This can be a tuple or struct to allow for multiple arguments. Because the returned future is 'static, the initializer can borrow a large argument, clone only the fields it needs, and move those fields into the future:
let repo = LazyCell::new(|ctx: &OperationContext| {
    let client = ctx.client.clone();
    let token = ctx.token.clone();

    async move { canonicalize(client, token).await }
});

let repo = LazyCell::try_force_with(&repo, &ctx).await?;

This implementation is in reference to #160. An example of it used in practice inside of OpenDAL's HuggingFace service can be found here.

@tisonkun

Copy link
Copy Markdown
Member

Thanks for your contribution @mhambre! Feel free to ping me when this PR is ready for review.

@mhambre

mhambre commented Aug 22, 2026

Copy link
Copy Markdown
Author

@tisonkun Before I put a bow on this, what are your thoughts on bullet #1? Supporting resumability would require some additional machinery, but I can implement it if we think that’s a semantic guarantee LazyLock should provide. I went with the simpler approach for now since I wasn’t convinced the additional complexity was justified without that requirement.

@tisonkun

Copy link
Copy Markdown
Member

Thanks for raising this. My current inclination is that LazyLock should represent a single asynchronous initialization: it should accept an AsyncFnOnce initializer and preserve/resume the produced future if the caller forcing it is cancelled.

Before settling the API, though, I think we'd better validate it against the motivating OpenDAL use case. Could you temporarily point OpenDAL at this PR’s commit by using a pinned git rev dependency or a local path override, and prototype replacing the relevant OnceCell with this LazyLock?

The key concern is when the initializer inputs become available. With OnceCell::get_or_try_init, the initializer is provided at access time, so it can borrow or receive the per-operation OperationContext. With LazyLock, the closure must be stored at construction time, when that context may not yet exist or may not be ownable for the required lifetime.

A small compiling prototype, ideally with the relevant test, would tell us whether a zero-argument stored initializer actually fits OpenDAL or whether the API/context ownership needs adjustment. If the use case works, I would prefer the AsyncFnOnce and resumable direction over restart-on-cancellation semantics.

BTW, I'd prefer to call the primitive LazyCell since we call OnceCell rather than OnceLock. There is no lock but actually an async version of the Cell abstraction.

@mhambre
mhambre force-pushed the feat/async-lazylock branch from c27f769 to 683cc8e Compare August 23, 2026 01:42
@mhambre

mhambre commented Aug 23, 2026

Copy link
Copy Markdown
Author

@tisonkun this should be good for a review. Went through and did some cleanup and feature changes aligned with your request. I tried to provide as much detail as possible in the PR description about design choices and API decisions. Here is the example PR in OpenDAL as requested apache/opendal#8133. If you need any clarification to help aid in your review feel free to ask.

@mhambre
mhambre marked this pull request as ready for review August 23, 2026 20:34
@mhambre mhambre changed the title feat: Add async LazyLock implementation feat: Add async LazyCell implementation Aug 23, 2026
@mhambre mhambre changed the title feat: Add async LazyCell implementation feat(once): Add async LazyCell implementation Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants