-
Notifications
You must be signed in to change notification settings - Fork 22
Scaffolding and bare-bones client #155
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| # Ruby Rust Bridge | ||
|
|
||
| This is a Ruby extension written in Rust to incorporate https://github.com/temporalio/sdk-core (included as a | ||
| submodule). This leverages https://github.com/oxidize-rb/rb-sys and https://github.com/matsadler/magnus. | ||
|
|
||
| ## Tokio Async to Ruby Async | ||
|
|
||
| There is no general accepted way to bridge the gap between async Tokio and async Ruby. It is important not only that | ||
| calls to/from Ruby happen with the GVL, but in threads _Ruby_ creates. C extensions cannot reasonably turn a non-Ruby | ||
| thread into a Ruby thread. | ||
|
|
||
| What the current implementation does is asks the Ruby side to instantiate and run a "command loop". This eats up a | ||
| single Ruby thread for the life of the runtime (which is usually the life of the process). This run-command-loop call | ||
| unlocks the GVL and waits on a Rust mpsc channel to feed it work. Ignoring shutdown machinations, the work is just a | ||
| Ruby callback. When the Rust channel receives a Ruby callback on this Ruby thread, it re-acquires the GVL and runs the | ||
| callback. Since this occurs serially for each callback in a single thread the callback is expected to be very fast. | ||
|
|
||
| Each thing that needs to do some async Tokio call, calls a helper with two things: the future to spawn in Tokio runtime, | ||
| and the callback to handle the results in the Ruby thread. All users of this helper are expected to do basically | ||
| everything in the async Tokio function, and do a very simple Ruby block call (or similar) in the callback. | ||
|
|
||
| So async calls usually looks like this: | ||
|
|
||
| * In Ruby, call method implemented in Rust, e.g. | ||
|
|
||
| ``` | ||
| queue = Queue.new | ||
| some_bridge_thing.do_foo { |result| Queue.push(result) } | ||
| queue.pop | ||
| ``` | ||
|
|
||
| * In Rust, `do_foo` spawns some Tokio async thing and returns | ||
| * Once Tokio async thing is completed, in the Ruby-thread callback, Rust side converts that thing to a Ruby thing and | ||
| invokes the block | ||
|
|
||
| This allows Ruby to remain async if in a Fiber, because Ruby `queue.pop` does not block a thread when in a Fiber | ||
| context. | ||
|
|
||
| The invocation of a block with a value is quite cheap in Ruby (`rb_proc_call_kw` C call). There are no obvious | ||
| performance savings by trying to push to a Ruby queue from inside Rust directly. | ||
|
|
||
| ## Argument Passing | ||
|
|
||
| For the smallest set of arguments, a simple positional or kwarg is fine. For anything larger, a `Struct` defined on the | ||
| Ruby side should be used. Parameters to Ruby functions defined in Rust should have no defaults. We want to make sure we | ||
| catch any missing arguments eagerly (e.g. the first test that even uses the struct/args). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| use std::{collections::HashMap, future::Future, time::Duration}; | ||
| use std::{collections::HashMap, future::Future, marker::PhantomData, time::Duration}; | ||
|
|
||
| use temporal_client::{ | ||
| ClientInitError, ClientKeepAliveConfig, ClientOptionsBuilder, ClientTlsConfig, | ||
|
|
@@ -13,7 +13,7 @@ use magnus::{ | |
| use tonic::{metadata::MetadataKey, Status}; | ||
| use url::Url; | ||
|
|
||
| use super::{error, new_error, ROOT_MOD}; | ||
| use super::{error, id, new_error, ROOT_MOD}; | ||
| use crate::{ | ||
| runtime::{Runtime, RuntimeHandle}, | ||
| util::Struct, | ||
|
|
@@ -82,19 +82,21 @@ impl Client { | |
| let mut opts_build = ClientOptionsBuilder::default(); | ||
| opts_build | ||
| .target_url( | ||
| Url::parse(format!("http://{}", options.aref::<String>("target_host")?).as_str()) | ||
| .map_err(|err| error!("Failed parsing host: {}", err))?, | ||
| Url::parse( | ||
| format!("http://{}", options.member::<String>(id!("target_host"))?).as_str(), | ||
| ) | ||
| .map_err(|err| error!("Failed parsing host: {}", err))?, | ||
| ) | ||
| .client_name(options.aref::<String>("client_name")?) | ||
| .client_version(options.aref::<String>("client_version")?) | ||
| .headers(Some(options.aref("rpc_metadata")?)) | ||
| .api_key(options.aref("api_key")?) | ||
| .identity(options.aref("identity")?); | ||
| if let Some(tls) = options.child("tls")? { | ||
| .client_name(options.member::<String>(id!("client_name"))?) | ||
| .client_version(options.member::<String>(id!("client_version"))?) | ||
| .headers(Some(options.member(id!("rpc_metadata"))?)) | ||
| .api_key(options.member(id!("api_key"))?) | ||
| .identity(options.member(id!("identity"))?); | ||
| if let Some(tls) = options.child(id!("tls"))? { | ||
| opts_build.tls_cfg(TlsConfig { | ||
| client_tls_config: match ( | ||
| tls.aref::<Option<RString>>("client_cert")?, | ||
| tls.aref::<Option<RString>>("client_private_key")?, | ||
| tls.member::<Option<RString>>(id!("client_cert"))?, | ||
| tls.member::<Option<RString>>(id!("client_private_key"))?, | ||
| ) { | ||
| (None, None) => None, | ||
| (Some(client_cert), Some(client_private_key)) => Some(ClientTlsConfig { | ||
|
|
@@ -109,38 +111,38 @@ impl Client { | |
| } | ||
| }, | ||
| server_root_ca_cert: tls | ||
| .aref::<Option<RString>>("server_root_ca_cert")? | ||
| .member::<Option<RString>>(id!("server_root_ca_cert"))? | ||
| .map(|rstr| unsafe { rstr.as_slice().to_vec() }), | ||
| domain: tls.aref("domain")?, | ||
| domain: tls.member(id!("domain"))?, | ||
| }); | ||
| } | ||
| let rpc_retry = options | ||
| .child("rpc_retry")? | ||
| .child(id!("rpc_retry"))? | ||
| .ok_or_else(|| error!("Missing rpc_retry"))?; | ||
| opts_build.retry_config(RetryConfig { | ||
| initial_interval: Duration::from_millis(rpc_retry.aref("initial_interval_ms")?), | ||
| randomization_factor: rpc_retry.aref("randomization_factor")?, | ||
| multiplier: rpc_retry.aref("multiplier")?, | ||
| max_interval: Duration::from_millis(rpc_retry.aref("max_interval_ms")?), | ||
| max_elapsed_time: match rpc_retry.aref::<u64>("max_elapsed_time_ms")? { | ||
| initial_interval: Duration::from_millis(rpc_retry.member(id!("initial_interval_ms"))?), | ||
| randomization_factor: rpc_retry.member(id!("randomization_factor"))?, | ||
| multiplier: rpc_retry.member(id!("multiplier"))?, | ||
| max_interval: Duration::from_millis(rpc_retry.member(id!("max_interval_ms"))?), | ||
| max_elapsed_time: match rpc_retry.member::<u64>(id!("max_elapsed_time_ms"))? { | ||
| // 0 means none | ||
| 0 => None, | ||
| val => Some(Duration::from_millis(val)), | ||
| }, | ||
| max_retries: rpc_retry.aref("max_retries")?, | ||
| max_retries: rpc_retry.member(id!("max_retries"))?, | ||
| }); | ||
| if let Some(keep_alive) = options.child("keep_alive")? { | ||
| if let Some(keep_alive) = options.child(id!("keep_alive"))? { | ||
| opts_build.keep_alive(Some(ClientKeepAliveConfig { | ||
| interval: Duration::from_millis(keep_alive.aref("interval_ms")?), | ||
| timeout: Duration::from_millis(keep_alive.aref("timeout_ms")?), | ||
| interval: Duration::from_millis(keep_alive.member(id!("interval_ms"))?), | ||
| timeout: Duration::from_millis(keep_alive.member(id!("timeout_ms"))?), | ||
| })); | ||
| } | ||
| if let Some(proxy) = options.child("http_connect_proxy")? { | ||
| if let Some(proxy) = options.child(id!("http_connect_proxy"))? { | ||
| opts_build.http_connect_proxy(Some(HttpConnectProxyOptions { | ||
| target_addr: proxy.aref("target_host")?, | ||
| target_addr: proxy.member(id!("target_host"))?, | ||
| basic_auth: match ( | ||
| proxy.aref::<Option<String>>("basic_auth_user")?, | ||
| proxy.aref::<Option<String>>("basic_auth_user")?, | ||
| proxy.member::<Option<String>>(id!("basic_auth_user"))?, | ||
| proxy.member::<Option<String>>(id!("basic_auth_user"))?, | ||
| ) { | ||
| (None, None) => None, | ||
| (Some(user), Some(pass)) => Some((user, pass)), | ||
|
|
@@ -165,21 +167,14 @@ impl Client { | |
| }, | ||
| move |ruby, result: Result<CoreClient, ClientInitError>| { | ||
| let block = ruby.get_inner(block); | ||
| match result { | ||
| Ok(core) => { | ||
| let _: Value = block | ||
| .call((Client { | ||
| core, | ||
| runtime_handle, | ||
| },)) | ||
| .expect("Block call failed"); | ||
| } | ||
| Err(err) => { | ||
| let _: Value = block | ||
| .call((new_error!("Failed client connect: {}", err),)) | ||
| .expect("Block call failed"); | ||
| } | ||
| let _: Value = match result { | ||
| Ok(core) => block.call((Client { | ||
| core, | ||
| runtime_handle, | ||
| },))?, | ||
| Err(err) => block.call((new_error!("Failed client connect: {}", err),))?, | ||
| }; | ||
| Ok(()) | ||
| }, | ||
| ); | ||
| Ok(()) | ||
|
|
@@ -195,12 +190,12 @@ impl Client { | |
| >( | ||
| args.keywords, | ||
| &[ | ||
| "service", | ||
| "rpc", | ||
| "request", | ||
| "rpc_retry", | ||
| "rpc_metadata", | ||
| "rpc_timeout_ms", | ||
| id!("service"), | ||
| id!("rpc"), | ||
| id!("request"), | ||
| id!("rpc_retry"), | ||
| id!("rpc_metadata"), | ||
| id!("rpc_timeout_ms"), | ||
| ], | ||
| &[], | ||
| )? | ||
|
|
@@ -211,6 +206,7 @@ impl Client { | |
| retry, | ||
| metadata, | ||
| timeout_ms, | ||
| _not_send_sync: PhantomData, | ||
| }; | ||
| let block = Opaque::from(args.block); | ||
| match service { | ||
|
|
@@ -261,6 +257,11 @@ struct RpcCall<'a> { | |
| retry: bool, | ||
| metadata: HashMap<String, String>, | ||
| timeout_ms: u64, | ||
|
|
||
| // This RPC call contains an unsafe reference to Ruby bytes that does not | ||
| // outlive the call, so we prevent it from being sent to another thread. | ||
| // !Send/!Sync not yet stable: https://github.com/rust-lang/rust/issues/68318 | ||
| _not_send_sync: PhantomData<*const ()>, | ||
| } | ||
|
|
||
| impl RpcCall<'_> { | ||
|
|
@@ -295,19 +296,13 @@ where | |
| async move { fut.await.map(|msg| msg.get_ref().encode_to_vec()) }, | ||
| move |ruby, result| { | ||
| let block = ruby.get_inner(block); | ||
| match result { | ||
| Ok(val) => { | ||
| // TODO(cretz): Any reasonable way to prevent byte copy? | ||
| let _: Value = block | ||
| .call((RString::from_slice(&val),)) | ||
| .expect("Block call failed"); | ||
| } | ||
| Err(status) => { | ||
| let _: Value = block | ||
| .call((RpcFailure { status },)) | ||
| .expect("Block call failed"); | ||
| } | ||
| let _: Value = match result { | ||
| // TODO(cretz): Any reasonable way to prevent byte copy that is just going to get decoded into proto | ||
| // object? | ||
|
Comment on lines
+300
to
+301
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There probably is, but it might also be more effort than it's worth. You'd have to pass around some explicit lifetime-having struct here or something. Hard for me to say without playing with the code myself but I could try it out if you like
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I think I found a way with But yeah it's a dangerous lifetime game to play save these cycles. I'll consider it premature optimization for now and we can come back later if needed. |
||
| Ok(val) => block.call((RString::from_slice(&val),))?, | ||
| Err(status) => block.call((RpcFailure { status },))?, | ||
| }; | ||
| Ok(()) | ||
| }, | ||
| ); | ||
| Ok(()) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
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.
There's a lot of things in this file that I blindly brought over from https://github.com/temporalio/sdk-core/blob/master/Cargo.toml. This is likely only going to every be a 1-member workspace (it's just needed at the top level for tooling). Consider getting rid of everything in here except
membersand moving things to specific project toml if they need to be retained.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.
Added docs to the top of this file explaining why it has to be this way right now