Skip to content
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

Add Promise support for http callout #265

Open
wants to merge 12 commits into
base: main
Choose a base branch
from

Conversation

jizhuozhi
Copy link

@jizhuozhi jizhuozhi commented Oct 11, 2024

In the current envoy WASM plugins, if we need IO request such as HTTP/GRPC/Redis, we must register request to envoy event loop and assigned a token via such as dispatch_http_call and WASM plugin should yield current request lifetime using Action::Pause. When the IO request completed, the envoy will callback to WASM plugin via on_http_call_response, and plugin should dispatch response using token (or ignored if single IO request). If we want to share something between dispatch_http_call and on_http_call_response, we must share them in plugin context fields, it's not a suitable scope.

Here is an example from proxy-wasm-rust-sdk

impl HttpContext for HttpAuthRandom {
    fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action {
        self.dispatch_http_call(
            "httpbin",
            vec![
                (":method", "GET"),
                (":path", "/bytes/1"),
                (":authority", "httpbin.org"),
            ],
            None,
            vec![],
            Duration::from_secs(1),
        )
        .unwrap();
        Action::Pause
    }

    fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action {
        self.set_http_response_header("Powered-By", Some("proxy-wasm"));
        Action::Continue
    }
}

impl Context for HttpAuthRandom {
    fn on_http_call_response(&mut self, _: u32, _: usize, body_size: usize, _: usize) {
        if let Some(body) = self.get_http_call_response_body(0, body_size) {
            if !body.is_empty() && body[0] % 2 == 0 {
                info!("Access granted.");
                self.resume_http_request();
                return;
            }
        }
        info!("Access forbidden.");
        self.send_http_response(
            403,
            vec![("Powered-By", "proxy-wasm")],
            Some(b"Access forbidden.\n"),
        );
    }
}

So there is three major problem we need to resolve:

  1. How to easier dispatch request and callback response via token.
  2. How to share something between dispatch request and callback response with smaller scope.
  3. How to make code more fluid instead of spreading logic in different places

In Rust async programming, normally we use async/await for IO request, but in envoy WASM plugin, there is no executor to poll future. A suitable solution is providing JavaScript style Promise (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). With Promise, we could write all logic in single function

impl HttpContext for HttpAuthRandom {
    fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action {
        let token = self.dispatch_http_call(
            "httpbin",
            vec![
                (":method", "GET"),
                (":path", "/bytes/1"),
                (":authority", "httpbin.org"),
            ],
            None,
            vec![],
            Duration::from_secs(1),
        )
        .unwrap();
        let promise = Promise::new();
        // make relation between promise and request (token)
        promise.then(|(_, _, body_size, _)| {
            if let Some(body) = hostcalls::get_http_call_response_body(0, body_size) {
                if !body.is_empty() && body[0] % 2 == 0 {
                    info!("Access granted.");
                    hostcalls::resume_http_request();
                    return;
                }
            }
            info!("Access forbidden.");
            hostcalls::send_http_response(
                403,
                vec![("Powered-By", "proxy-wasm")],
                Some(b"Access forbidden.\n"),
            );
        })
        Action::Pause
    }

    fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action {
        self.set_http_response_header("Powered-By", Some("proxy-wasm"));
        Action::Continue
    }
}

It seems more fluid then writing in callback of on_http_call_response, but there is no executor for promise to trigger state transferring. We can use on_http_call_response as trigger simply

struct HttpAuthRandom {
    promise: Rc<Promise<(u32, usize, usize, usize)>>
}

impl HttpContext for HttpAuthRandom {
    fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action {
      // ...
      // make relation between promise and request (token)
      self.promise = promise.clone();
      // ...
    }
}

impl Context for HttpAuthRandom {
    fn on_http_call_response(&mut self, _token_id: u32, _num_headers: usize, _body_size: usize, _num_trailers: usize) {
        self.promise.fulfill((_token_id, _num_headers, _body_size, _num_trailers))
    }
}

As for making relationship between multi tokens and promises, we could just using HashMap with insert/remove (maybe it should be embed in SDK but not belongs to this PR)

struct HttpAuthRandom {
    m: HashMap<u32, Rc<Promise<(u32, usize, usize, usize)>>
}

impl HttpContext for HttpAuthRandom {
    fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action {
      // ...
      // make relation between promise and request (token)
      self.m.insert(token, promise.clone());
      // ...
    }
}

impl Context for HttpAuthRandom {
    fn on_http_call_response(&mut self, _token_id: u32, _num_headers: usize, _body_size: usize, _num_trailers: usize) {
        let promise = self.m.remove(token);
        promise.fulfill((_token_id, _num_headers, _body_size, _num_trailers))
    }
}

Note: hostcalls is re-implemented of resume_http_request and send_http_response because we cannot move self to callbacks.

fix ci

Signed-off-by: jizhuozhi.george <[email protected]>
ci add `http_parallel_call`

Signed-off-by: jizhuozhi.george <[email protected]>
@jizhuozhi
Copy link
Author

jizhuozhi commented Oct 14, 2024

msrv/stable/nightly will fix, but another outdated/reactors seems not belong to this PR, PTAL :)

fix ci

Signed-off-by: jizhuozhi.george <[email protected]>
examples/http_parallel_call/Cargo.toml Outdated Show resolved Hide resolved
examples/http_parallel_call/src/lib.rs Outdated Show resolved Hide resolved
examples/http_parallel_call/src/lib.rs Outdated Show resolved Hide resolved
examples/http_parallel_call/src/lib.rs Outdated Show resolved Hide resolved
examples/http_parallel_call/src/lib.rs Outdated Show resolved Hide resolved
src/promise.rs Outdated Show resolved Hide resolved
src/promise.rs Outdated Show resolved Hide resolved
@PiotrSikora
Copy link
Member

msrv/stable/nightly will fix, but another outdated/reactors seems not belong to this PR, PTAL :)

Thanks! I'll fix existing errors shortly. Sorry about that!

move promise and dispatch http request to callout folder

Signed-off-by: jizhuozhi.george <[email protected]>
remove Chinese comments

Signed-off-by: jizhuozhi.george <[email protected]>
@jizhuozhi
Copy link
Author

There is currently a problem involving the collaboration between SDK and user code: HttpClient is created by user code, but the callback is driven by the SDK. There is currently no way to shield this detail within the SDK. User code always needs to write boilerplate code in on_http_call_response to driver callback.

I have no idea to resolve this problem :(

jizhuozhi and others added 5 commits October 14, 2024 17:07
simplify http_parallel_call example

Signed-off-by: jizhuozhi.george <[email protected]>
fix ci

Signed-off-by: jizhuozhi.george <[email protected]>
fix example cloning self to move but not using hostcalls

Signed-off-by: jizhuozhi.george <[email protected]>
fix licenses and bazel (maybe)

Signed-off-by: jizhuozhi.george <[email protected]>
@jizhuozhi
Copy link
Author

jizhuozhi commented Nov 3, 2024

Bazel BUILD file has been reformatted :)

@PiotrSikora
Copy link
Member

There is currently a problem involving the collaboration between SDK and user code: HttpClient is created by user code, but the callback is driven by the SDK. There is currently no way to shield this detail within the SDK. User code always needs to write boilerplate code in on_http_call_response to driver callback.

I think (but didn't check the code) that this boilerplate could be included in the dispatcher, and executed automatically when events for registered callouts are received from the host... or am I missing something?

@@ -0,0 +1,341 @@
// Copyright 2024 Google LLC
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you look at std::future and/or futures-rs crate? It seems like they are implementing the same concepts, so I'm wondering how much of the Promise code we could avoid?

Copy link
Author

@jizhuozhi jizhuozhi Dec 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

std::future in c++ is an async primitive to get result in multi-thread programming. std::future produced by std::async or std::promise. Once program calls std::future::get, it will block until std::async completed or std::promise::set called. It means at least two threads for provider and consumer but wasm has no (once call dispatched, thread will be used to process other requests).

And future-rs is providing the foundations for asynchronous programming in Rust, and lazy executed which requires an executor to poll the result (which means all operations must be non-blocking). The problem as same asstd::future, there is no thread as executor to poll the result.

So the only way that we could own the thread is as callback of http response.

@jizhuozhi
Copy link
Author

There is currently a problem involving the collaboration between SDK and user code: HttpClient is created by user code, but the callback is driven by the SDK. There is currently no way to shield this detail within the SDK. User code always needs to write boilerplate code in on_http_call_response to driver callback.

I think (but didn't check the code) that this boilerplate could be included in the dispatcher, and executed automatically when events for registered callouts are received from the host... or am I missing something?

The Go SDK handles it this way, but I'm not sure if doing so breaks the Rust SDK API

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