From 9e807772988e1c36a609841ac20730e8baed2e67 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 02:05:32 +0800 Subject: [PATCH 01/23] feat: add raw unary runtime core --- Cargo.toml | 1 + dubbo-runtime/Cargo.toml | 13 ++ dubbo-runtime/src/lib.rs | 127 +++++++++++++++++++ dubbo/src/protocol/triple/triple_invoker.rs | 46 +++++-- dubbo/src/triple/client/triple.rs | 117 +++++++++++++++-- dubbo/src/triple/codec/bytes.rs | 98 +++++++++++++++ dubbo/src/triple/codec/mod.rs | 1 + dubbo/tests/raw_unary.rs | 133 ++++++++++++++++++++ 8 files changed, 511 insertions(+), 25 deletions(-) create mode 100644 dubbo-runtime/Cargo.toml create mode 100644 dubbo-runtime/src/lib.rs create mode 100644 dubbo/src/triple/codec/bytes.rs create mode 100644 dubbo/tests/raw_unary.rs diff --git a/Cargo.toml b/Cargo.toml index 02c1e2f6..a7ba42b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "registry/zookeeper", "registry/nacos", "dubbo", + "dubbo-runtime", "examples/echo", "examples/greeter", "dubbo-build", diff --git a/dubbo-runtime/Cargo.toml b/dubbo-runtime/Cargo.toml new file mode 100644 index 00000000..8dd9340d --- /dev/null +++ b/dubbo-runtime/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "dubbo-runtime" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Stable runtime facade for embedding Dubbo Rust core" +repository = "https://github.com/apache/dubbo-rust.git" + +[dependencies] +bytes.workspace = true +dubbo = { path = "../dubbo", version = "0.4.0" } +http = "0.2" + diff --git a/dubbo-runtime/src/lib.rs b/dubbo-runtime/src/lib.rs new file mode 100644 index 00000000..964d0be8 --- /dev/null +++ b/dubbo-runtime/src/lib.rs @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use bytes::Bytes; +use dubbo::{ + codegen::{ClientBuilder, Request, RpcInvocation, TripleClient}, + invocation::Metadata, +}; + +/// A stable raw Triple client facade for embedding Dubbo Rust in other runtimes. +/// +/// This is intentionally byte-oriented. Hosts such as Node.js should own their +/// protobuf message shape and use this facade for Dubbo transport and governance. +pub struct RawTripleClient { + inner: TripleClient, +} + +impl RawTripleClient { + pub fn from_static(endpoint: &str) -> Self { + let builder = ClientBuilder::from_static(endpoint).with_direct(true); + Self { + inner: TripleClient::new(builder), + } + } + + pub async fn unary( + &mut self, + request: RawUnaryRequest, + ) -> Result { + let path = request.path.parse().map_err(|err| { + dubbo::status::Status::new( + dubbo::status::Code::InvalidArgument, + format!("invalid request path: {err}"), + ) + })?; + let invocation = RpcInvocation::default() + .with_service_unique_name(request.service) + .with_method_name(request.method); + let response = self + .inner + .raw_unary( + Request::from_parts(request.metadata.into(), request.body), + path, + invocation, + ) + .await?; + let (metadata, body) = response.into_parts(); + + Ok(RawUnaryResponse { + metadata: metadata.into(), + body, + }) + } +} + +#[derive(Debug, Clone)] +pub struct RawUnaryRequest { + pub service: String, + pub method: String, + pub path: String, + pub metadata: RawMetadata, + pub body: Bytes, +} + +#[derive(Debug, Clone)] +pub struct RawUnaryResponse { + pub metadata: RawMetadata, + pub body: Bytes, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RawMetadata { + pub entries: Vec<(String, String)>, +} + +impl RawMetadata { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(mut self, key: impl Into, value: impl Into) -> Self { + self.entries.push((key.into(), value.into())); + self + } +} + +impl From for Metadata { + fn from(value: RawMetadata) -> Self { + value + .entries + .into_iter() + .fold(Metadata::default(), |metadata, (key, value)| { + metadata.insert(key, value) + }) + } +} + +impl From for RawMetadata { + fn from(value: Metadata) -> Self { + let entries = value + .into_headers() + .iter() + .filter_map(|(key, value)| { + value + .to_str() + .ok() + .map(|value| (key.to_string(), value.to_string())) + }) + .collect(); + + Self { entries } + } +} diff --git a/dubbo/src/protocol/triple/triple_invoker.rs b/dubbo/src/protocol/triple/triple_invoker.rs index 516dab2e..b80c18d7 100644 --- a/dubbo/src/protocol/triple/triple_invoker.rs +++ b/dubbo/src/protocol/triple/triple_invoker.rs @@ -88,21 +88,33 @@ impl TripleInvoker { "authority", HeaderValue::from_str(uri.authority().unwrap().as_str()).unwrap(), ); - req.headers_mut().insert( + insert_default_header( + req.headers_mut(), "content-type", HeaderValue::from_static("application/grpc+proto"), ); - req.headers_mut() - .insert("user-agent", HeaderValue::from_static("dubbo-rust/0.1.0")); - req.headers_mut() - .insert("te", HeaderValue::from_static("trailers")); - req.headers_mut().insert( + insert_default_header( + req.headers_mut(), + "user-agent", + HeaderValue::from_static("dubbo-rust/0.1.0"), + ); + insert_default_header( + req.headers_mut(), + "te", + HeaderValue::from_static("trailers"), + ); + insert_default_header( + req.headers_mut(), "tri-service-version", HeaderValue::from_static("dubbo-rust/0.1.0"), ); - req.headers_mut() - .insert("tri-service-group", HeaderValue::from_static("cluster")); - req.headers_mut().insert( + insert_default_header( + req.headers_mut(), + "tri-service-group", + HeaderValue::from_static("cluster"), + ); + insert_default_header( + req.headers_mut(), "tri-unit-info", HeaderValue::from_static("dubbo-rust/0.1.0"), ); @@ -110,10 +122,14 @@ impl TripleInvoker { // } - req.headers_mut() - .insert("grpc-encoding", http::HeaderValue::from_static("gzip")); + insert_default_header( + req.headers_mut(), + "grpc-encoding", + http::HeaderValue::from_static("gzip"), + ); - req.headers_mut().insert( + insert_default_header( + req.headers_mut(), "grpc-accept-encoding", http::HeaderValue::from_static("gzip"), ); @@ -134,6 +150,12 @@ impl TripleInvoker { } } +fn insert_default_header(headers: &mut http::HeaderMap, name: &'static str, value: HeaderValue) { + if !headers.contains_key(name) { + headers.insert(name, value); + } +} + impl Service> for TripleInvoker { type Response = http::Response; diff --git a/dubbo/src/triple/client/triple.rs b/dubbo/src/triple/client/triple.rs index 2948dec5..d44c51d8 100644 --- a/dubbo/src/triple/client/triple.rs +++ b/dubbo/src/triple/client/triple.rs @@ -16,6 +16,7 @@ */ use aws_smithy_http::body::SdkBody; +use bytes::Bytes; use futures_util::{future, stream, StreamExt, TryStreamExt}; use http::HeaderValue; use prost::Message; @@ -29,7 +30,7 @@ use crate::{ status::Status, svc::NewService, triple::{ - codec::{Codec, Decoder, Encoder}, + codec::{bytes::BytesCodec, Codec, Decoder, Encoder}, compression::CompressionEncoding, decode::Decoding, encode::encode, @@ -92,28 +93,44 @@ impl TripleClient { "authority", HeaderValue::from_str(uri.authority().unwrap().as_str()).unwrap(), ); - req.headers_mut().insert( + insert_default_header( + req.headers_mut(), "content-type", HeaderValue::from_static("application/grpc+proto"), ); - req.headers_mut() - .insert("user-agent", HeaderValue::from_static("dubbo-rust/0.1.0")); - req.headers_mut() - .insert("te", HeaderValue::from_static("trailers")); - req.headers_mut().insert( + insert_default_header( + req.headers_mut(), + "user-agent", + HeaderValue::from_static("dubbo-rust/0.1.0"), + ); + insert_default_header( + req.headers_mut(), + "te", + HeaderValue::from_static("trailers"), + ); + insert_default_header( + req.headers_mut(), "tri-service-version", HeaderValue::from_static("dubbo-rust/0.1.0"), ); - req.headers_mut() - .insert("tri-service-group", HeaderValue::from_static("cluster")); - req.headers_mut().insert( + insert_default_header( + req.headers_mut(), + "tri-service-group", + HeaderValue::from_static("cluster"), + ); + insert_default_header( + req.headers_mut(), "tri-unit-info", HeaderValue::from_static("dubbo-rust/0.1.0"), ); if let Some(_encoding) = self.send_compression_encoding { - req.headers_mut() - .insert("grpc-encoding", http::HeaderValue::from_static("gzip")); - req.headers_mut().insert( + insert_default_header( + req.headers_mut(), + "grpc-encoding", + http::HeaderValue::from_static("gzip"), + ); + insert_default_header( + req.headers_mut(), "grpc-accept-encoding", http::HeaderValue::from_static("gzip"), ); @@ -204,6 +221,74 @@ impl TripleClient { } } + pub async fn raw_unary( + &mut self, + req: Request, + path: http::uri::PathAndQuery, + mut invocation: RpcInvocation, + ) -> Result, crate::status::Status> { + let mut codec = BytesCodec::default(); + let decoder: Box + Send + 'static> = + Box::new(codec.decoder()); + let encoder: Box + Send + 'static> = + Box::new(codec.encoder()); + + let mt = req.metadata.clone(); + + let req = req.map(|m| stream::once(future::ready(m))); + let body_stream = encode( + encoder, + req.into_inner().map(Ok), + self.send_compression_encoding, + true, + ) + .into_stream(); + let body = hyper::Body::wrap_stream(body_stream); + + invocation = invocation.with_metadata(mt.clone()); + let mut invoker = self.mk.new_service(invocation); + + let mut request = http::Request::builder() + .header("path", path.to_string()) + .body(body) + .unwrap(); + + for (k, v) in mt.into_headers().iter() { + request.headers_mut().insert(k, v.to_owned()); + } + + let response = invoker + .call(request) + .await + .map_err(|err| crate::status::Status::from_error(err.into())); + + match response { + Ok(v) => { + let resp = v + .map(|body| Decoding::new(body, decoder, self.send_compression_encoding, true)); + let (mut parts, body) = Response::from_http(resp).into_parts(); + + futures_util::pin_mut!(body); + + let message = body.try_next().await?.ok_or_else(|| { + crate::status::Status::new( + crate::status::Code::Internal, + "Missing response message.".to_string(), + ) + })?; + + if let Some(trailers) = body.trailer().await? { + let mut h = parts.into_headers(); + h.extend(trailers.into_headers()); + parts = Metadata::from_headers(h); + } + + Ok(Response::from_parts(parts, message)) + } + Err(err) => Err(err), + } + } + pub async fn bidi_streaming( &mut self, req: impl IntoStreamingRequest, @@ -385,6 +470,12 @@ impl TripleClient { } } +fn insert_default_header(headers: &mut http::HeaderMap, name: &'static str, value: HeaderValue) { + if !headers.contains_key(name) { + headers.insert(name, value); + } +} + pub fn get_codec( content_type: &str, ) -> ( diff --git a/dubbo/src/triple/codec/bytes.rs b/dubbo/src/triple/codec/bytes.rs new file mode 100644 index 00000000..1fff6c85 --- /dev/null +++ b/dubbo/src/triple/codec/bytes.rs @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use bytes::{Buf, BufMut, Bytes}; + +use super::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder}; + +/// A codec that treats each Triple message as an opaque protobuf payload. +/// +/// This is the boundary needed by higher-level runtimes such as JavaScript: +/// JS can own protobuf encoding/decoding while Rust owns Triple transport, +/// registry, routing, and connection state. +#[derive(Debug, Clone, Default)] +pub struct BytesCodec; + +impl Codec for BytesCodec { + type Encode = Bytes; + type Decode = Bytes; + + type Encoder = BytesEncoder; + type Decoder = BytesDecoder; + + fn encoder(&mut self) -> Self::Encoder { + BytesEncoder + } + + fn decoder(&mut self) -> Self::Decoder { + BytesDecoder + } +} + +#[derive(Debug, Clone, Default)] +pub struct BytesEncoder; + +impl Encoder for BytesEncoder { + type Item = Bytes; + type Error = crate::status::Status; + + fn encode(&mut self, item: Self::Item, buf: &mut EncodeBuf<'_>) -> Result<(), Self::Error> { + buf.reserve(item.len()); + buf.put_slice(&item); + Ok(()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct BytesDecoder; + +impl Decoder for BytesDecoder { + type Item = Bytes; + type Error = crate::status::Status; + + fn decode(&mut self, buf: &mut DecodeBuf<'_>) -> Result, Self::Error> { + let len = buf.remaining(); + Ok(Some(buf.copy_to_bytes(len))) + } +} + +#[cfg(test)] +mod tests { + use bytes::BytesMut; + + use super::*; + + #[test] + fn bytes_codec_preserves_payload() { + let payload = Bytes::from_static(b"\x08\x96\x01"); + let mut encoded = BytesMut::new(); + let mut encoder = BytesEncoder; + + encoder + .encode(payload.clone(), &mut EncodeBuf::new(&mut encoded)) + .unwrap(); + + let len = encoded.len(); + let mut decoder = BytesDecoder; + let decoded = decoder + .decode(&mut DecodeBuf::new(&mut encoded, len)) + .unwrap() + .unwrap(); + + assert_eq!(decoded, payload); + } +} diff --git a/dubbo/src/triple/codec/mod.rs b/dubbo/src/triple/codec/mod.rs index 2ba79865..9b891de8 100644 --- a/dubbo/src/triple/codec/mod.rs +++ b/dubbo/src/triple/codec/mod.rs @@ -16,6 +16,7 @@ */ pub mod buffer; +pub mod bytes; pub mod prost; pub mod serde_codec; diff --git a/dubbo/tests/raw_unary.rs b/dubbo/tests/raw_unary.rs new file mode 100644 index 00000000..a514f637 --- /dev/null +++ b/dubbo/tests/raw_unary.rs @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::{ + convert::Infallible, + future::Future, + net::SocketAddr, + pin::Pin, + task::{Context, Poll}, +}; + +use bytes::{BufMut, Bytes, BytesMut}; +use dubbo::{ + codegen::{ClientBuilder, Request, RpcInvocation, TripleClient}, + triple::transport::DubboServer, + BoxBody, +}; +use http_body::Body; +use tower_service::Service; + +#[derive(Clone)] +struct RawUnaryService; + +impl Service> for RawUnaryService { + type Response = http::Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + Box::pin(async move { + assert_eq!(req.uri().path(), "/grpc.examples.echo.Echo/UnaryEcho"); + assert_eq!( + req.headers() + .get("tri-service-group") + .and_then(|v| v.to_str().ok()), + Some("test") + ); + assert_eq!( + req.headers() + .get("tri-service-version") + .and_then(|v| v.to_str().ok()), + Some("1.0.0") + ); + + let response_payload = Bytes::from_static(b"\x0a\x0draw response"); + let body = http_body::combinators::UnsyncBoxBody::new( + hyper::Body::from(grpc_frame(response_payload)).map_err(|err| { + dubbo::status::Status::new(dubbo::status::Code::Internal, err.to_string()) + }), + ); + + Ok(http::Response::builder() + .status(http::StatusCode::OK) + .header("content-type", "application/grpc+proto") + .body(body) + .unwrap()) + }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn raw_unary_round_trips_protobuf_bytes() { + let addr = unused_local_addr(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let server_task = tokio::spawn(async move { + DubboServer::new() + .with_listener("tcp".to_string()) + .add_service("grpc.examples.echo.Echo".to_string(), RawUnaryService) + .serve_with_graceful(addr, async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let endpoint = format!("http://{addr}?interface=grpc.examples.echo.Echo"); + let builder = ClientBuilder::from_static(&endpoint).with_direct(true); + let mut client = TripleClient::new(builder); + let path = http::uri::PathAndQuery::from_static("/grpc.examples.echo.Echo/UnaryEcho"); + let invocation = RpcInvocation::default() + .with_service_unique_name("grpc.examples.echo.Echo".to_string()) + .with_method_name("UnaryEcho".to_string()); + let request = Request::from_parts( + dubbo::invocation::Metadata::default() + .insert("tri-service-group".to_string(), "test".to_string()) + .insert("tri-service-version".to_string(), "1.0.0".to_string()), + Bytes::from_static(b"\x0a\x08dubbo-js"), + ); + + let response = client.raw_unary(request, path, invocation).await.unwrap(); + + let (_, response_bytes) = response.into_parts(); + assert_eq!(response_bytes, Bytes::from_static(b"\x0a\x0draw response")); + + let _ = shutdown_tx.send(()); + server_task.await.unwrap(); +} + +fn grpc_frame(payload: Bytes) -> Bytes { + let mut frame = BytesMut::with_capacity(5 + payload.len()); + frame.put_u8(0); + frame.put_u32(payload.len() as u32); + frame.extend_from_slice(&payload); + frame.freeze() +} + +fn unused_local_addr() -> SocketAddr { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() +} From 7de774a5ce68b3b8e7cca9d01e529a20e9ec06d0 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 02:14:48 +0800 Subject: [PATCH 02/23] feat: add node native napi binding crate --- Cargo.toml | 1 + dubbo-node-native/Cargo.toml | 20 ++++ dubbo-node-native/build.rs | 3 + dubbo-node-native/src/lib.rs | 210 +++++++++++++++++++++++++++++++++++ 4 files changed, 234 insertions(+) create mode 100644 dubbo-node-native/Cargo.toml create mode 100644 dubbo-node-native/build.rs create mode 100644 dubbo-node-native/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index a7ba42b9..76918e1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "registry/nacos", "dubbo", "dubbo-runtime", + "dubbo-node-native", "examples/echo", "examples/greeter", "dubbo-build", diff --git a/dubbo-node-native/Cargo.toml b/dubbo-node-native/Cargo.toml new file mode 100644 index 00000000..0bb32548 --- /dev/null +++ b/dubbo-node-native/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dubbo-node-native" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "N-API bindings for embedding Dubbo Rust core in dubbo-js" +repository = "https://github.com/apache/dubbo-rust.git" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +bytes.workspace = true +dubbo-runtime = { path = "../dubbo-runtime", version = "0.1.0" } +napi = { version = "2", features = ["napi8", "tokio_rt"] } +napi-derive = "2" +tokio = { workspace = true, features = ["rt-multi-thread"] } + +[build-dependencies] +napi-build = "2" diff --git a/dubbo-node-native/build.rs b/dubbo-node-native/build.rs new file mode 100644 index 00000000..0f1b0100 --- /dev/null +++ b/dubbo-node-native/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/dubbo-node-native/src/lib.rs b/dubbo-node-native/src/lib.rs new file mode 100644 index 00000000..34799585 --- /dev/null +++ b/dubbo-node-native/src/lib.rs @@ -0,0 +1,210 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use bytes::Bytes; +use dubbo_runtime::{RawMetadata, RawTripleClient, RawUnaryRequest as RuntimeUnaryRequest}; +use napi::{ + bindgen_prelude::{AsyncTask, Buffer}, + Error, Result, Status, Task, +}; +use napi_derive::napi; +use tokio::runtime::{Builder, Runtime}; + +#[napi(object)] +pub struct NativeDubboTransportOptions { + #[napi(js_name = "baseUrl")] + pub base_url: Option, + pub registry: Option, + pub protocol: Option, + #[napi(js_name = "timeoutMs")] + pub timeout_ms: Option, + #[napi(js_name = "loadBalance")] + pub load_balance: Option, + pub cluster: Option, + pub group: Option, + pub version: Option, +} + +#[napi(object)] +pub struct NativeUnaryRequest { + pub service: String, + pub method: String, + pub path: String, + pub headers: Option>, + pub body: Buffer, + #[napi(js_name = "timeoutMs")] + pub timeout_ms: Option, + pub group: Option, + pub version: Option, +} + +#[napi(object)] +pub struct NativeUnaryResponse { + pub code: u32, + pub message: Option, + pub headers: HashMap, + pub trailers: HashMap, + pub body: Buffer, +} + +#[napi] +pub struct NativeTransport { + client: Arc>, + runtime: Arc, +} + +#[napi] +impl NativeTransport { + #[napi] + pub fn unary(&self, request: NativeUnaryRequest) -> AsyncTask { + AsyncTask::new(UnaryTask { + client: Arc::clone(&self.client), + runtime: Arc::clone(&self.runtime), + request, + }) + } + + #[napi] + pub fn close(&self) -> Result<()> { + Ok(()) + } +} + +#[napi] +pub fn create_native_transport(options: NativeDubboTransportOptions) -> Result { + if options + .protocol + .as_deref() + .is_some_and(|protocol| protocol != "triple") + { + return Err(Error::new( + Status::InvalidArg, + "only the triple protocol is supported by dubbo-node-native".to_string(), + )); + } + + if options.registry.is_some() { + return Err(Error::new( + Status::InvalidArg, + "registry URLs are not supported by dubbo-node-native yet; use baseUrl for direct Triple calls".to_string(), + )); + } + + let base_url = options.base_url.ok_or_else(|| { + Error::new( + Status::InvalidArg, + "baseUrl is required until registry support lands".to_string(), + ) + })?; + + let runtime = Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|err| Error::new(Status::GenericFailure, err.to_string()))?; + + Ok(NativeTransport { + client: Arc::new(Mutex::new(RawTripleClient::from_static(&base_url))), + runtime: Arc::new(runtime), + }) +} + +pub struct UnaryTask { + client: Arc>, + runtime: Arc, + request: NativeUnaryRequest, +} + +impl Task for UnaryTask { + type Output = NativeUnaryResponse; + type JsValue = NativeUnaryResponse; + + fn compute(&mut self) -> Result { + let request = std::mem::replace( + &mut self.request, + NativeUnaryRequest { + service: String::new(), + method: String::new(), + path: String::new(), + headers: None, + body: Buffer::from(Vec::new()), + timeout_ms: None, + group: None, + version: None, + }, + ); + let mut client = self + .client + .lock() + .map_err(|_| Error::new(Status::GenericFailure, "native transport lock poisoned"))?; + + let response = self + .runtime + .block_on(client.unary(runtime_request(request))); + + response + .map(native_response) + .map_err(|err| Error::new(Status::GenericFailure, err.to_string())) + } + + fn resolve(&mut self, _env: napi::Env, output: Self::Output) -> Result { + Ok(output) + } +} + +fn runtime_request(request: NativeUnaryRequest) -> RuntimeUnaryRequest { + let mut metadata = request + .headers + .unwrap_or_default() + .into_iter() + .fold(RawMetadata::new(), |metadata, (key, value)| { + metadata.insert(key, value) + }); + + if let Some(group) = request.group { + metadata = metadata.insert("tri-service-group", group); + } + if let Some(version) = request.version { + metadata = metadata.insert("tri-service-version", version); + } + + RuntimeUnaryRequest { + service: request.service, + method: request.method, + path: request.path, + metadata, + body: Bytes::from(request.body.to_vec()), + } +} + +fn native_response(response: dubbo_runtime::RawUnaryResponse) -> NativeUnaryResponse { + NativeUnaryResponse { + code: 0, + message: None, + headers: metadata_entries(response.metadata), + trailers: HashMap::new(), + body: Buffer::from(response.body.to_vec()), + } +} + +fn metadata_entries(metadata: RawMetadata) -> HashMap { + metadata.entries.into_iter().collect() +} From 9ae78cd91d5b19a5732794853c64f2a1a67cbed2 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 02:21:25 +0800 Subject: [PATCH 03/23] refactor: rename runtime facade to dubbo rs core --- Cargo.toml | 2 +- dubbo-node-native/Cargo.toml | 2 +- dubbo-node-native/src/lib.rs | 12 +++++------- {dubbo-runtime => dubbo-rs-core}/Cargo.toml | 5 ++--- {dubbo-runtime => dubbo-rs-core}/src/lib.rs | 0 5 files changed, 9 insertions(+), 12 deletions(-) rename {dubbo-runtime => dubbo-rs-core}/Cargo.toml (69%) rename {dubbo-runtime => dubbo-rs-core}/src/lib.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 76918e1d..ef1591a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = [ "registry/zookeeper", "registry/nacos", "dubbo", - "dubbo-runtime", + "dubbo-rs-core", "dubbo-node-native", "examples/echo", "examples/greeter", diff --git a/dubbo-node-native/Cargo.toml b/dubbo-node-native/Cargo.toml index 0bb32548..90d991cf 100644 --- a/dubbo-node-native/Cargo.toml +++ b/dubbo-node-native/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib"] [dependencies] bytes.workspace = true -dubbo-runtime = { path = "../dubbo-runtime", version = "0.1.0" } +dubbo-rs-core = { path = "../dubbo-rs-core", version = "0.1.0" } napi = { version = "2", features = ["napi8", "tokio_rt"] } napi-derive = "2" tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/dubbo-node-native/src/lib.rs b/dubbo-node-native/src/lib.rs index 34799585..fa8d8c5a 100644 --- a/dubbo-node-native/src/lib.rs +++ b/dubbo-node-native/src/lib.rs @@ -21,7 +21,7 @@ use std::{ }; use bytes::Bytes; -use dubbo_runtime::{RawMetadata, RawTripleClient, RawUnaryRequest as RuntimeUnaryRequest}; +use dubbo_rs_core::{RawMetadata, RawTripleClient, RawUnaryRequest as CoreUnaryRequest}; use napi::{ bindgen_prelude::{AsyncTask, Buffer}, Error, Result, Status, Task, @@ -156,9 +156,7 @@ impl Task for UnaryTask { .lock() .map_err(|_| Error::new(Status::GenericFailure, "native transport lock poisoned"))?; - let response = self - .runtime - .block_on(client.unary(runtime_request(request))); + let response = self.runtime.block_on(client.unary(core_request(request))); response .map(native_response) @@ -170,7 +168,7 @@ impl Task for UnaryTask { } } -fn runtime_request(request: NativeUnaryRequest) -> RuntimeUnaryRequest { +fn core_request(request: NativeUnaryRequest) -> CoreUnaryRequest { let mut metadata = request .headers .unwrap_or_default() @@ -186,7 +184,7 @@ fn runtime_request(request: NativeUnaryRequest) -> RuntimeUnaryRequest { metadata = metadata.insert("tri-service-version", version); } - RuntimeUnaryRequest { + CoreUnaryRequest { service: request.service, method: request.method, path: request.path, @@ -195,7 +193,7 @@ fn runtime_request(request: NativeUnaryRequest) -> RuntimeUnaryRequest { } } -fn native_response(response: dubbo_runtime::RawUnaryResponse) -> NativeUnaryResponse { +fn native_response(response: dubbo_rs_core::RawUnaryResponse) -> NativeUnaryResponse { NativeUnaryResponse { code: 0, message: None, diff --git a/dubbo-runtime/Cargo.toml b/dubbo-rs-core/Cargo.toml similarity index 69% rename from dubbo-runtime/Cargo.toml rename to dubbo-rs-core/Cargo.toml index 8dd9340d..762555b2 100644 --- a/dubbo-runtime/Cargo.toml +++ b/dubbo-rs-core/Cargo.toml @@ -1,13 +1,12 @@ [package] -name = "dubbo-runtime" +name = "dubbo-rs-core" version = "0.1.0" edition = "2021" license = "Apache-2.0" -description = "Stable runtime facade for embedding Dubbo Rust core" +description = "Stable core facade for embedding Dubbo Rust in other runtimes" repository = "https://github.com/apache/dubbo-rust.git" [dependencies] bytes.workspace = true dubbo = { path = "../dubbo", version = "0.4.0" } http = "0.2" - diff --git a/dubbo-runtime/src/lib.rs b/dubbo-rs-core/src/lib.rs similarity index 100% rename from dubbo-runtime/src/lib.rs rename to dubbo-rs-core/src/lib.rs From 3db8408bcef3f115fabdf0c79f05eac5428163e9 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 02:22:13 +0800 Subject: [PATCH 04/23] refactor: keep napi binding out of rust core workspace --- Cargo.toml | 1 - dubbo-node-native/Cargo.toml | 20 ---- dubbo-node-native/build.rs | 3 - dubbo-node-native/src/lib.rs | 208 ----------------------------------- 4 files changed, 232 deletions(-) delete mode 100644 dubbo-node-native/Cargo.toml delete mode 100644 dubbo-node-native/build.rs delete mode 100644 dubbo-node-native/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index ef1591a7..50666bc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,6 @@ members = [ "registry/nacos", "dubbo", "dubbo-rs-core", - "dubbo-node-native", "examples/echo", "examples/greeter", "dubbo-build", diff --git a/dubbo-node-native/Cargo.toml b/dubbo-node-native/Cargo.toml deleted file mode 100644 index 90d991cf..00000000 --- a/dubbo-node-native/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "dubbo-node-native" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" -description = "N-API bindings for embedding Dubbo Rust core in dubbo-js" -repository = "https://github.com/apache/dubbo-rust.git" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -bytes.workspace = true -dubbo-rs-core = { path = "../dubbo-rs-core", version = "0.1.0" } -napi = { version = "2", features = ["napi8", "tokio_rt"] } -napi-derive = "2" -tokio = { workspace = true, features = ["rt-multi-thread"] } - -[build-dependencies] -napi-build = "2" diff --git a/dubbo-node-native/build.rs b/dubbo-node-native/build.rs deleted file mode 100644 index 0f1b0100..00000000 --- a/dubbo-node-native/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - napi_build::setup(); -} diff --git a/dubbo-node-native/src/lib.rs b/dubbo-node-native/src/lib.rs deleted file mode 100644 index fa8d8c5a..00000000 --- a/dubbo-node-native/src/lib.rs +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -use std::{ - collections::HashMap, - sync::{Arc, Mutex}, -}; - -use bytes::Bytes; -use dubbo_rs_core::{RawMetadata, RawTripleClient, RawUnaryRequest as CoreUnaryRequest}; -use napi::{ - bindgen_prelude::{AsyncTask, Buffer}, - Error, Result, Status, Task, -}; -use napi_derive::napi; -use tokio::runtime::{Builder, Runtime}; - -#[napi(object)] -pub struct NativeDubboTransportOptions { - #[napi(js_name = "baseUrl")] - pub base_url: Option, - pub registry: Option, - pub protocol: Option, - #[napi(js_name = "timeoutMs")] - pub timeout_ms: Option, - #[napi(js_name = "loadBalance")] - pub load_balance: Option, - pub cluster: Option, - pub group: Option, - pub version: Option, -} - -#[napi(object)] -pub struct NativeUnaryRequest { - pub service: String, - pub method: String, - pub path: String, - pub headers: Option>, - pub body: Buffer, - #[napi(js_name = "timeoutMs")] - pub timeout_ms: Option, - pub group: Option, - pub version: Option, -} - -#[napi(object)] -pub struct NativeUnaryResponse { - pub code: u32, - pub message: Option, - pub headers: HashMap, - pub trailers: HashMap, - pub body: Buffer, -} - -#[napi] -pub struct NativeTransport { - client: Arc>, - runtime: Arc, -} - -#[napi] -impl NativeTransport { - #[napi] - pub fn unary(&self, request: NativeUnaryRequest) -> AsyncTask { - AsyncTask::new(UnaryTask { - client: Arc::clone(&self.client), - runtime: Arc::clone(&self.runtime), - request, - }) - } - - #[napi] - pub fn close(&self) -> Result<()> { - Ok(()) - } -} - -#[napi] -pub fn create_native_transport(options: NativeDubboTransportOptions) -> Result { - if options - .protocol - .as_deref() - .is_some_and(|protocol| protocol != "triple") - { - return Err(Error::new( - Status::InvalidArg, - "only the triple protocol is supported by dubbo-node-native".to_string(), - )); - } - - if options.registry.is_some() { - return Err(Error::new( - Status::InvalidArg, - "registry URLs are not supported by dubbo-node-native yet; use baseUrl for direct Triple calls".to_string(), - )); - } - - let base_url = options.base_url.ok_or_else(|| { - Error::new( - Status::InvalidArg, - "baseUrl is required until registry support lands".to_string(), - ) - })?; - - let runtime = Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|err| Error::new(Status::GenericFailure, err.to_string()))?; - - Ok(NativeTransport { - client: Arc::new(Mutex::new(RawTripleClient::from_static(&base_url))), - runtime: Arc::new(runtime), - }) -} - -pub struct UnaryTask { - client: Arc>, - runtime: Arc, - request: NativeUnaryRequest, -} - -impl Task for UnaryTask { - type Output = NativeUnaryResponse; - type JsValue = NativeUnaryResponse; - - fn compute(&mut self) -> Result { - let request = std::mem::replace( - &mut self.request, - NativeUnaryRequest { - service: String::new(), - method: String::new(), - path: String::new(), - headers: None, - body: Buffer::from(Vec::new()), - timeout_ms: None, - group: None, - version: None, - }, - ); - let mut client = self - .client - .lock() - .map_err(|_| Error::new(Status::GenericFailure, "native transport lock poisoned"))?; - - let response = self.runtime.block_on(client.unary(core_request(request))); - - response - .map(native_response) - .map_err(|err| Error::new(Status::GenericFailure, err.to_string())) - } - - fn resolve(&mut self, _env: napi::Env, output: Self::Output) -> Result { - Ok(output) - } -} - -fn core_request(request: NativeUnaryRequest) -> CoreUnaryRequest { - let mut metadata = request - .headers - .unwrap_or_default() - .into_iter() - .fold(RawMetadata::new(), |metadata, (key, value)| { - metadata.insert(key, value) - }); - - if let Some(group) = request.group { - metadata = metadata.insert("tri-service-group", group); - } - if let Some(version) = request.version { - metadata = metadata.insert("tri-service-version", version); - } - - CoreUnaryRequest { - service: request.service, - method: request.method, - path: request.path, - metadata, - body: Bytes::from(request.body.to_vec()), - } -} - -fn native_response(response: dubbo_rs_core::RawUnaryResponse) -> NativeUnaryResponse { - NativeUnaryResponse { - code: 0, - message: None, - headers: metadata_entries(response.metadata), - trailers: HashMap::new(), - body: Buffer::from(response.body.to_vec()), - } -} - -fn metadata_entries(metadata: RawMetadata) -> HashMap { - metadata.entries.into_iter().collect() -} From 96adcc3dc01b94a54b3924c5b1e31d276b011755 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 02:37:55 +0800 Subject: [PATCH 05/23] feat: add raw unary timeout to core --- dubbo-rs-core/Cargo.toml | 7 ++ dubbo-rs-core/src/lib.rs | 30 ++++++-- dubbo-rs-core/tests/raw_unary.rs | 123 +++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 dubbo-rs-core/tests/raw_unary.rs diff --git a/dubbo-rs-core/Cargo.toml b/dubbo-rs-core/Cargo.toml index 762555b2..68326393 100644 --- a/dubbo-rs-core/Cargo.toml +++ b/dubbo-rs-core/Cargo.toml @@ -10,3 +10,10 @@ repository = "https://github.com/apache/dubbo-rust.git" bytes.workspace = true dubbo = { path = "../dubbo", version = "0.4.0" } http = "0.2" +tokio = { workspace = true, features = ["time"] } + +[dev-dependencies] +http-body = "0.4.4" +hyper = { version = "0.14.26", features = ["full"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +tower-service.workspace = true diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index 964d0be8..40e9d257 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -15,10 +15,13 @@ * limitations under the License. */ +use std::time::Duration; + use bytes::Bytes; use dubbo::{ codegen::{ClientBuilder, Request, RpcInvocation, TripleClient}, invocation::Metadata, + status::{Code, Status}, }; /// A stable raw Triple client facade for embedding Dubbo Rust in other runtimes. @@ -50,14 +53,24 @@ impl RawTripleClient { let invocation = RpcInvocation::default() .with_service_unique_name(request.service) .with_method_name(request.method); - let response = self - .inner - .raw_unary( - Request::from_parts(request.metadata.into(), request.body), - path, - invocation, - ) - .await?; + let timeout_ms = request.timeout_ms; + let call = self.inner.raw_unary( + Request::from_parts(request.metadata.into(), request.body), + path, + invocation, + ); + let response = if let Some(timeout_ms) = timeout_ms { + tokio::time::timeout(Duration::from_millis(timeout_ms), call) + .await + .map_err(|_| { + Status::new( + Code::DeadlineExceeded, + format!("request timed out after {timeout_ms}ms"), + ) + })?? + } else { + call.await? + }; let (metadata, body) = response.into_parts(); Ok(RawUnaryResponse { @@ -74,6 +87,7 @@ pub struct RawUnaryRequest { pub path: String, pub metadata: RawMetadata, pub body: Bytes, + pub timeout_ms: Option, } #[derive(Debug, Clone)] diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs new file mode 100644 index 00000000..9a89197b --- /dev/null +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::{ + convert::Infallible, + future::Future, + net::SocketAddr, + pin::Pin, + task::{Context, Poll}, + time::Duration, +}; + +use bytes::{BufMut, Bytes, BytesMut}; +use dubbo::{status::Code, triple::transport::DubboServer, BoxBody}; +use dubbo_rs_core::{RawMetadata, RawTripleClient, RawUnaryRequest}; +use http_body::Body; +use tower_service::Service; + +#[derive(Clone)] +struct SlowRawUnaryService { + delay: Duration, +} + +impl Service> for SlowRawUnaryService { + type Response = http::Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: http::Request) -> Self::Future { + let delay = self.delay; + Box::pin(async move { + tokio::time::sleep(delay).await; + let response_payload = Bytes::from_static(b"\x0a\x0draw response"); + let body = http_body::combinators::UnsyncBoxBody::new( + hyper::Body::from(grpc_frame(response_payload)).map_err(|err| { + dubbo::status::Status::new(dubbo::status::Code::Internal, err.to_string()) + }), + ); + + Ok(http::Response::builder() + .status(http::StatusCode::OK) + .header("content-type", "application/grpc+proto") + .body(body) + .unwrap()) + }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn raw_unary_timeout_returns_deadline_exceeded() { + let addr = unused_local_addr(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let server_task = tokio::spawn(async move { + DubboServer::new() + .with_listener("tcp".to_string()) + .add_service( + "grpc.examples.echo.Echo".to_string(), + SlowRawUnaryService { + delay: Duration::from_millis(100), + }, + ) + .serve_with_graceful(addr, async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let endpoint = format!("http://{addr}?interface=grpc.examples.echo.Echo"); + let mut client = RawTripleClient::from_static(&endpoint); + let err = client + .unary(RawUnaryRequest { + service: "grpc.examples.echo.Echo".to_string(), + method: "UnaryEcho".to_string(), + path: "/grpc.examples.echo.Echo/UnaryEcho".to_string(), + metadata: RawMetadata::new(), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(10), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::DeadlineExceeded); + + let _ = shutdown_tx.send(()); + server_task.await.unwrap(); +} + +fn grpc_frame(payload: Bytes) -> Bytes { + let mut frame = BytesMut::with_capacity(5 + payload.len()); + frame.put_u8(0); + frame.put_u32(payload.len() as u32); + frame.extend_from_slice(&payload); + frame.freeze() +} + +fn unused_local_addr() -> SocketAddr { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() +} From b390c010b737af1866dbae4694dd8be67423a32b Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 02:48:06 +0800 Subject: [PATCH 06/23] feat: expose static endpoint list in core --- dubbo-rs-core/src/lib.rs | 31 +++++++++- dubbo-rs-core/tests/raw_unary.rs | 99 ++++++++++++++++++++++++------ dubbo/src/triple/client/builder.rs | 18 +++++- 3 files changed, 125 insertions(+), 23 deletions(-) diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index 40e9d257..3b593dc8 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -22,6 +22,7 @@ use dubbo::{ codegen::{ClientBuilder, Request, RpcInvocation, TripleClient}, invocation::Metadata, status::{Code, Status}, + Url, }; /// A stable raw Triple client facade for embedding Dubbo Rust in other runtimes. @@ -34,10 +35,34 @@ pub struct RawTripleClient { impl RawTripleClient { pub fn from_static(endpoint: &str) -> Self { - let builder = ClientBuilder::from_static(endpoint).with_direct(true); - Self { - inner: TripleClient::new(builder), + Self::from_static_endpoints([endpoint]).expect("static endpoint must be a valid Dubbo URL") + } + + pub fn from_static_endpoints<'a, I>(endpoints: I) -> Result + where + I: IntoIterator, + { + let endpoints = endpoints + .into_iter() + .map(|endpoint| { + endpoint.parse::().map_err(|err| { + Status::new( + Code::InvalidArgument, + format!("invalid static endpoint {endpoint}: {err}"), + ) + }) + }) + .collect::, _>>()?; + if endpoints.is_empty() { + return Err(Status::new( + Code::InvalidArgument, + "at least one static endpoint is required".to_string(), + )); } + let builder = ClientBuilder::from_static_urls(endpoints).with_direct(true); + Ok(Self { + inner: TripleClient::new(builder), + }) } pub async fn unary( diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index 9a89197b..18384846 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -64,36 +64,66 @@ impl Service> for SlowRawUnaryService { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_uses_static_endpoint_list() { + const SERVICE: &str = "grpc.examples.echo.StaticEndpointEcho"; + let addr = unused_local_addr(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let server_task = spawn_server( + addr, + shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(0), + ); + + wait_for_server(addr).await; + + let first_endpoint = format!("http://{addr}?interface={SERVICE}&instance=one"); + let second_endpoint = format!("http://{addr}?interface={SERVICE}&instance=two"); + let mut client = + RawTripleClient::from_static_endpoints([first_endpoint.as_str(), second_endpoint.as_str()]) + .unwrap(); + let response = client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new(), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(2_000), + }) + .await + .unwrap(); + + assert_eq!(response.body, Bytes::from_static(b"\x0a\x0draw response")); + + let _ = shutdown_tx.send(()); + server_task.await.unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn raw_unary_timeout_returns_deadline_exceeded() { + const SERVICE: &str = "grpc.examples.echo.TimeoutEcho"; let addr = unused_local_addr(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let server_task = tokio::spawn(async move { - DubboServer::new() - .with_listener("tcp".to_string()) - .add_service( - "grpc.examples.echo.Echo".to_string(), - SlowRawUnaryService { - delay: Duration::from_millis(100), - }, - ) - .serve_with_graceful(addr, async move { - let _ = shutdown_rx.await; - }) - .await - .unwrap(); - }); + let server_task = spawn_server( + addr, + shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(100), + ); - tokio::time::sleep(Duration::from_millis(100)).await; + wait_for_server(addr).await; - let endpoint = format!("http://{addr}?interface=grpc.examples.echo.Echo"); + let endpoint = format!("http://{addr}?interface={SERVICE}"); let mut client = RawTripleClient::from_static(&endpoint); let err = client .unary(RawUnaryRequest { - service: "grpc.examples.echo.Echo".to_string(), + service: SERVICE.to_string(), method: "UnaryEcho".to_string(), - path: "/grpc.examples.echo.Echo/UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), metadata: RawMetadata::new(), body: Bytes::from_static(b"\x0a\x08dubbo-js"), timeout_ms: Some(10), @@ -107,6 +137,37 @@ async fn raw_unary_timeout_returns_deadline_exceeded() { server_task.await.unwrap(); } +fn spawn_server( + addr: SocketAddr, + shutdown_rx: tokio::sync::oneshot::Receiver<()>, + service_name: String, + delay: Duration, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + DubboServer::new() + .with_listener("tcp".to_string()) + .add_service(service_name, SlowRawUnaryService { delay }) + .serve_with_graceful(addr, async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }) +} + +async fn wait_for_server(addr: SocketAddr) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(1); + loop { + if tokio::net::TcpStream::connect(addr).await.is_ok() { + return; + } + if tokio::time::Instant::now() >= deadline { + panic!("server did not start listening on {addr}"); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + fn grpc_frame(payload: Bytes) -> Bytes { let mut frame = BytesMut::with_capacity(5 + payload.len()); frame.put_u8(0); diff --git a/dubbo/src/triple/client/builder.rs b/dubbo/src/triple/client/builder.rs index ce0fa661..b10fc706 100644 --- a/dubbo/src/triple/client/builder.rs +++ b/dubbo/src/triple/client/builder.rs @@ -54,7 +54,23 @@ impl ClientBuilder { } pub fn from_static(host: &str) -> ClientBuilder { - let registry_extension_url = StaticRegistry::to_extension_url(vec![host.parse().unwrap()]); + Self::from_static_hosts([host]) + } + + pub fn from_static_hosts<'a, I>(hosts: I) -> ClientBuilder + where + I: IntoIterator, + { + Self::from_static_urls( + hosts + .into_iter() + .map(|host| host.parse().unwrap()) + .collect(), + ) + } + + pub fn from_static_urls(urls: Vec) -> ClientBuilder { + let registry_extension_url = StaticRegistry::to_extension_url(urls); Self { timeout: None, connector: "", From 7ef071d44ece3f576b56dca3bc7841367b153398 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 02:58:19 +0800 Subject: [PATCH 07/23] feat: add registry constructor to core --- dubbo-rs-core/Cargo.toml | 8 +++ dubbo-rs-core/src/lib.rs | 65 +++++++++++++++++++++++ dubbo-rs-core/tests/raw_unary.rs | 8 ++- dubbo/src/extension/registry_extension.rs | 4 +- registry/zookeeper/src/lib.rs | 28 +++++++++- 5 files changed, 106 insertions(+), 7 deletions(-) diff --git a/dubbo-rs-core/Cargo.toml b/dubbo-rs-core/Cargo.toml index 68326393..b78f5414 100644 --- a/dubbo-rs-core/Cargo.toml +++ b/dubbo-rs-core/Cargo.toml @@ -9,9 +9,17 @@ repository = "https://github.com/apache/dubbo-rust.git" [dependencies] bytes.workspace = true dubbo = { path = "../dubbo", version = "0.4.0" } +dubbo-registry-nacos = { path = "../registry/nacos", version = "0.4.0", optional = true } +dubbo-registry-zookeeper = { path = "../registry/zookeeper", version = "0.4.0", optional = true } http = "0.2" tokio = { workspace = true, features = ["time"] } +[features] +default = [] +registry-nacos = ["dep:dubbo-registry-nacos"] +registry-zookeeper = ["dep:dubbo-registry-zookeeper"] +registry = ["registry-nacos", "registry-zookeeper"] + [dev-dependencies] http-body = "0.4.4" hyper = { version = "0.14.26", features = ["full"] } diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index 3b593dc8..55b6d92f 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -24,6 +24,10 @@ use dubbo::{ status::{Code, Status}, Url, }; +#[cfg(feature = "registry-nacos")] +use dubbo_registry_nacos::NacosRegistry; +#[cfg(feature = "registry-zookeeper")] +use dubbo_registry_zookeeper::ZookeeperRegistry; /// A stable raw Triple client facade for embedding Dubbo Rust in other runtimes. /// @@ -65,6 +69,22 @@ impl RawTripleClient { }) } + pub async fn from_registry(registry_url: &str) -> Result { + let registry_url = registry_url.parse::().map_err(|err| { + Status::new( + Code::InvalidArgument, + format!("invalid registry URL {registry_url}: {err}"), + ) + })?; + + register_registry_extension(registry_url.protocol()).await?; + + let builder = ClientBuilder::new().with_registry(registry_url); + Ok(Self { + inner: TripleClient::new(builder), + }) + } + pub async fn unary( &mut self, request: RawUnaryRequest, @@ -105,6 +125,34 @@ impl RawTripleClient { } } +async fn register_registry_extension(protocol: &str) -> Result<(), Status> { + match protocol { + #[cfg(feature = "registry-nacos")] + "nacos" => dubbo::extension::EXTENSIONS + .register::>() + .await + .map_err(registry_extension_error), + #[cfg(feature = "registry-zookeeper")] + "zookeeper" => dubbo::extension::EXTENSIONS + .register::>( + ) + .await + .map_err(registry_extension_error), + protocol => Err(Status::new( + Code::Unimplemented, + format!("registry protocol {protocol} is not enabled in dubbo-rs-core"), + )), + } +} + +#[cfg(any(feature = "registry-nacos", feature = "registry-zookeeper"))] +fn registry_extension_error(err: dubbo::StdError) -> Status { + Status::new( + Code::Internal, + format!("failed to register registry extension: {err}"), + ) +} + #[derive(Debug, Clone)] pub struct RawUnaryRequest { pub service: String, @@ -164,3 +212,20 @@ impl From for RawMetadata { Self { entries } } } + +#[cfg(all( + test, + not(any(feature = "registry-nacos", feature = "registry-zookeeper")) +))] +mod tests { + use super::*; + + #[tokio::test] + async fn from_registry_reports_disabled_backend() { + let result = RawTripleClient::from_registry("nacos://127.0.0.1:8848").await; + match result { + Ok(_) => panic!("registry client should not be created without registry features"), + Err(err) => assert_eq!(err.code(), Code::Unimplemented), + } + } +} diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index 18384846..9943b3c5 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -65,6 +65,11 @@ impl Service> for SlowRawUnaryService { } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_uses_static_endpoint_list_and_maps_timeout() { + raw_unary_uses_static_endpoint_list().await; + raw_unary_timeout_returns_deadline_exceeded().await; +} + async fn raw_unary_uses_static_endpoint_list() { const SERVICE: &str = "grpc.examples.echo.StaticEndpointEcho"; let addr = unused_local_addr(); @@ -91,7 +96,7 @@ async fn raw_unary_uses_static_endpoint_list() { path: format!("/{SERVICE}/UnaryEcho"), metadata: RawMetadata::new(), body: Bytes::from_static(b"\x0a\x08dubbo-js"), - timeout_ms: Some(2_000), + timeout_ms: Some(10_000), }) .await .unwrap(); @@ -102,7 +107,6 @@ async fn raw_unary_uses_static_endpoint_list() { server_task.await.unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn raw_unary_timeout_returns_deadline_exceeded() { const SERVICE: &str = "grpc.examples.echo.TimeoutEcho"; let addr = unused_local_addr(); diff --git a/dubbo/src/extension/registry_extension.rs b/dubbo/src/extension/registry_extension.rs index 2e9291b3..4c132bab 100644 --- a/dubbo/src/extension/registry_extension.rs +++ b/dubbo/src/extension/registry_extension.rs @@ -141,9 +141,7 @@ impl RegistryExtensionFactory { &mut self, url: Url, ) -> Result, StdError> { - let registry_url = url.query::().unwrap(); - let registry_url = registry_url.value(); - let url_str = registry_url.as_str().to_string(); + let url_str = url.as_str().to_string(); match self.instances.get(&url_str) { Some(proxy) => { let proxy = proxy.clone(); diff --git a/registry/zookeeper/src/lib.rs b/registry/zookeeper/src/lib.rs index 8de2bd16..1812aaef 100644 --- a/registry/zookeeper/src/lib.rs +++ b/registry/zookeeper/src/lib.rs @@ -31,8 +31,11 @@ use tokio::{select, sync::mpsc}; use zookeeper::{Acl, CreateMode, WatchedEvent, WatchedEventType, Watcher, ZooKeeper}; use dubbo::{ - extension::registry_extension::{DiscoverStream, Registry, ServiceChange}, - params::registry_param::InterfaceName, + extension::{ + registry_extension::{DiscoverStream, Registry, ServiceChange}, + Extension, + }, + params::registry_param::{InterfaceName, RegistryUrl}, }; // Get metadata of a service registration from a URL @@ -260,6 +263,27 @@ impl Default for ZookeeperRegistry { } } +#[async_trait] +impl Extension for ZookeeperRegistry { + type Target = Box; + + fn name() -> String { + "zookeeper".to_string() + } + + async fn create(url: Url) -> Result { + // url example: + // extension://0.0.0.0?extension-type=registry&extension-name=zookeeper®istry=zookeeper://127.0.0.1:2181 + let registry_url = url.query::().unwrap(); + let registry_url = registry_url.value(); + let host = registry_url.host().unwrap_or("127.0.0.1"); + let port = registry_url.port().unwrap_or(2181); + let connect_string = format!("{host}:{port}"); + + Ok(Box::new(ZookeeperRegistry::new(&connect_string))) + } +} + #[async_trait] impl Registry for ZookeeperRegistry { async fn register(&self, url: Url) -> Result<(), StdError> { From 905a208135cf45ba2180ec0e6a8e49cb1cde76af Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 03:03:20 +0800 Subject: [PATCH 08/23] feat: support configurable load balancing --- dubbo-rs-core/src/lib.rs | 61 +++++++++++++++++++++- dubbo/src/loadbalancer/mod.rs | 82 +++++++++++++++++++++++++----- dubbo/src/triple/client/builder.rs | 20 ++++++-- 3 files changed, 146 insertions(+), 17 deletions(-) diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index 55b6d92f..354561bd 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -21,6 +21,7 @@ use bytes::Bytes; use dubbo::{ codegen::{ClientBuilder, Request, RpcInvocation, TripleClient}, invocation::Metadata, + loadbalancer::LoadBalanceStrategy, status::{Code, Status}, Url, }; @@ -43,6 +44,16 @@ impl RawTripleClient { } pub fn from_static_endpoints<'a, I>(endpoints: I) -> Result + where + I: IntoIterator, + { + Self::from_static_endpoints_with_options(endpoints, RawTripleClientOptions::default()) + } + + pub fn from_static_endpoints_with_options<'a, I>( + endpoints: I, + options: RawTripleClientOptions, + ) -> Result where I: IntoIterator, { @@ -63,13 +74,21 @@ impl RawTripleClient { "at least one static endpoint is required".to_string(), )); } - let builder = ClientBuilder::from_static_urls(endpoints).with_direct(true); + let builder = + options.apply(ClientBuilder::from_static_urls(endpoints).with_direct(true))?; Ok(Self { inner: TripleClient::new(builder), }) } pub async fn from_registry(registry_url: &str) -> Result { + Self::from_registry_with_options(registry_url, RawTripleClientOptions::default()).await + } + + pub async fn from_registry_with_options( + registry_url: &str, + options: RawTripleClientOptions, + ) -> Result { let registry_url = registry_url.parse::().map_err(|err| { Status::new( Code::InvalidArgument, @@ -79,7 +98,7 @@ impl RawTripleClient { register_registry_extension(registry_url.protocol()).await?; - let builder = ClientBuilder::new().with_registry(registry_url); + let builder = options.apply(ClientBuilder::new().with_registry(registry_url))?; Ok(Self { inner: TripleClient::new(builder), }) @@ -125,6 +144,30 @@ impl RawTripleClient { } } +#[derive(Debug, Clone, Default)] +pub struct RawTripleClientOptions { + pub load_balance: Option, +} + +impl RawTripleClientOptions { + fn apply(&self, builder: ClientBuilder) -> Result { + match self.load_balance.as_deref() { + Some(load_balance) => { + let strategy = LoadBalanceStrategy::parse(load_balance).ok_or_else(|| { + Status::new( + Code::InvalidArgument, + format!( + "unsupported load balance {load_balance}; expected random, round_robin, or p2c" + ), + ) + })?; + Ok(builder.with_load_balance(strategy)) + } + None => Ok(builder), + } + } +} + async fn register_registry_extension(protocol: &str) -> Result<(), Status> { match protocol { #[cfg(feature = "registry-nacos")] @@ -220,6 +263,20 @@ impl From for RawMetadata { mod tests { use super::*; + #[test] + fn static_client_rejects_unknown_load_balance() { + let result = RawTripleClient::from_static_endpoints_with_options( + ["http://127.0.0.1:50051?interface=example.Echo"], + RawTripleClientOptions { + load_balance: Some("least_active".to_string()), + }, + ); + match result { + Ok(_) => panic!("client should reject unsupported load balance strategy"), + Err(err) => assert_eq!(err.code(), Code::InvalidArgument), + } + } + #[tokio::test] async fn from_registry_reports_disabled_backend() { let result = RawTripleClient::from_registry("nacos://127.0.0.1:8848").await; diff --git a/dubbo/src/loadbalancer/mod.rs b/dubbo/src/loadbalancer/mod.rs index d49ec5df..0855d9b9 100644 --- a/dubbo/src/loadbalancer/mod.rs +++ b/dubbo/src/loadbalancer/mod.rs @@ -18,7 +18,13 @@ pub mod random; use futures_core::future::BoxFuture; -use std::error::Error; +use std::{ + error::Error, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, +}; use tokio::time::Duration; use tower::{discover::ServiceList, ServiceExt}; use tower_service::Service; @@ -37,18 +43,22 @@ use crate::{ pub struct NewLoadBalancer { inner: N, + strategy: LoadBalanceStrategy, } #[derive(Clone)] pub struct LoadBalancerSvc { inner: S, // Routes service + strategy: LoadBalanceStrategy, + round_robin_next: Arc, } impl NewLoadBalancer { - pub fn layer() -> impl tower_layer::Layer { - tower_layer::layer_fn(|inner| { + pub fn layer(strategy: LoadBalanceStrategy) -> impl tower_layer::Layer { + tower_layer::layer_fn(move |inner| { NewLoadBalancer { inner, // NewRoutes + strategy: strategy.clone(), } }) } @@ -66,7 +76,11 @@ where // Routes service let svc = self.inner.new_service(target); - LoadBalancerSvc { inner: svc } + LoadBalancerSvc { + inner: svc, + strategy: self.strategy.clone(), + round_robin_next: Arc::new(AtomicUsize::new(0)), + } } } @@ -92,6 +106,8 @@ where fn call(&mut self, req: http::Request) -> Self::Future { let routes = self.inner.call(()); + let strategy = self.strategy.clone(); + let round_robin_next = Arc::clone(&self.round_robin_next); let fut = async move { let routes = routes.await; @@ -114,7 +130,7 @@ where // let p2c = tower::balance::p2c::Balance::new(service_list); // let p: Box, http::Response>, Box>> + std::marker::Send + std::marker::Sync> = get_loadbalancer("p2c").into(); - let p = get_loadbalancer("p2c"); + let p = get_loadbalancer(&strategy, round_robin_next); // let ivk = p.select_invokers(invokers, metadata); let ivk = p.select_invokers(routes, metadata); @@ -141,16 +157,33 @@ pub trait LoadBalancer { ) -> Self::Invoker; } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum LoadBalanceStrategy { + Random, + RoundRobin, + #[default] + P2c, +} + +impl LoadBalanceStrategy { + pub fn parse(strategy: &str) -> Option { + match strategy { + "random" => Some(Self::Random), + "round_robin" | "roundrobin" => Some(Self::RoundRobin), + "p2c" => Some(Self::P2c), + _ => None, + } + } +} + fn get_loadbalancer( - loadbalancer: &str, + loadbalancer: &LoadBalanceStrategy, + round_robin_next: Arc, ) -> Box + Send + Sync + 'static> { match loadbalancer { - "random" => { - println!("random!"); - Box::new(RandomLoadBalancer::default()) - } - "p2c" => Box::new(P2cBalancer::default()), - _ => Box::new(P2cBalancer::default()), + LoadBalanceStrategy::Random => Box::new(RandomLoadBalancer::default()), + LoadBalanceStrategy::RoundRobin => Box::new(RoundRobinLoadBalancer::new(round_robin_next)), + LoadBalanceStrategy::P2c => Box::new(P2cBalancer::default()), } } const DEFAULT_RTT: Duration = Duration::from_millis(30); @@ -185,3 +218,28 @@ impl LoadBalancer for P2cBalancer { svc } } + +#[derive(Debug)] +pub struct RoundRobinLoadBalancer { + next: Arc, +} + +impl RoundRobinLoadBalancer { + pub fn new(next: Arc) -> Self { + Self { next } + } +} + +impl LoadBalancer for RoundRobinLoadBalancer { + type Invoker = DubboBoxService; + + fn select_invokers( + &self, + invokers: Vec>, + _metadata: Metadata, + ) -> Self::Invoker { + debug!("round-robin load balancer"); + let index = self.next.fetch_add(1, Ordering::Relaxed) % invokers.len(); + DubboBoxService::new(invokers[index].clone()) + } +} diff --git a/dubbo/src/triple/client/builder.rs b/dubbo/src/triple/client/builder.rs index b10fc706..6b6d5bde 100644 --- a/dubbo/src/triple/client/builder.rs +++ b/dubbo/src/triple/client/builder.rs @@ -18,8 +18,12 @@ use std::sync::Arc; use crate::{ - cluster::NewCluster, directory::NewCachedDirectory, extension, loadbalancer::NewLoadBalancer, - route::NewRoutes, utils::boxed_clone::BoxCloneService, + cluster::NewCluster, + directory::NewCachedDirectory, + extension, + loadbalancer::{LoadBalanceStrategy, NewLoadBalancer}, + route::NewRoutes, + utils::boxed_clone::BoxCloneService, }; use crate::{ @@ -41,6 +45,7 @@ pub struct ClientBuilder { pub connector: &'static str, registry_extension_url: Option, pub direct: bool, + pub load_balance: LoadBalanceStrategy, } impl ClientBuilder { @@ -50,6 +55,7 @@ impl ClientBuilder { connector: "", registry_extension_url: None, direct: false, + load_balance: LoadBalanceStrategy::default(), } } @@ -76,6 +82,7 @@ impl ClientBuilder { connector: "", registry_extension_url: Some(registry_extension_url), direct: true, + load_balance: LoadBalanceStrategy::default(), } } @@ -111,6 +118,13 @@ impl ClientBuilder { Self { direct, ..self } } + pub fn with_load_balance(self, load_balance: LoadBalanceStrategy) -> Self { + Self { + load_balance, + ..self + } + } + pub fn build(mut self) -> ServiceMK { let registry = self .registry_extension_url @@ -119,7 +133,7 @@ impl ClientBuilder { let mk_service = ServiceBuilder::new() .layer(NewCluster::layer()) - .layer(NewLoadBalancer::layer()) + .layer(NewLoadBalancer::layer(self.load_balance)) .layer(NewRoutes::layer()) .layer(NewCachedDirectory::layer()) .service(MkRegistryService::new(registry)); From 2aec41d483d3f623fbbaabac150c241d881874ca Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 03:10:36 +0800 Subject: [PATCH 09/23] feat: support configurable cluster strategy --- dubbo-rs-core/src/lib.rs | 41 +++++++++++-- dubbo/src/cluster/mod.rs | 93 +++++++++++++++++++++++++----- dubbo/src/triple/client/builder.rs | 11 +++- 3 files changed, 126 insertions(+), 19 deletions(-) diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index 354561bd..aff10cff 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -19,6 +19,7 @@ use std::time::Duration; use bytes::Bytes; use dubbo::{ + cluster::ClusterStrategy, codegen::{ClientBuilder, Request, RpcInvocation, TripleClient}, invocation::Metadata, loadbalancer::LoadBalanceStrategy, @@ -147,11 +148,12 @@ impl RawTripleClient { #[derive(Debug, Clone, Default)] pub struct RawTripleClientOptions { pub load_balance: Option, + pub cluster: Option, } impl RawTripleClientOptions { fn apply(&self, builder: ClientBuilder) -> Result { - match self.load_balance.as_deref() { + let builder = match self.load_balance.as_deref() { Some(load_balance) => { let strategy = LoadBalanceStrategy::parse(load_balance).ok_or_else(|| { Status::new( @@ -161,10 +163,25 @@ impl RawTripleClientOptions { ), ) })?; - Ok(builder.with_load_balance(strategy)) + builder.with_load_balance(strategy) } - None => Ok(builder), - } + None => builder, + }; + + let builder = match self.cluster.as_deref() { + Some(cluster) => { + let strategy = ClusterStrategy::parse(cluster).ok_or_else(|| { + Status::new( + Code::InvalidArgument, + format!("unsupported cluster {cluster}; expected failfast or failover"), + ) + })?; + builder.with_cluster(strategy) + } + None => builder, + }; + + Ok(builder) } } @@ -269,6 +286,7 @@ mod tests { ["http://127.0.0.1:50051?interface=example.Echo"], RawTripleClientOptions { load_balance: Some("least_active".to_string()), + ..RawTripleClientOptions::default() }, ); match result { @@ -277,6 +295,21 @@ mod tests { } } + #[test] + fn static_client_rejects_unknown_cluster() { + let result = RawTripleClient::from_static_endpoints_with_options( + ["http://127.0.0.1:50051?interface=example.Echo"], + RawTripleClientOptions { + cluster: Some("failsafe".to_string()), + ..RawTripleClientOptions::default() + }, + ); + match result { + Ok(_) => panic!("client should reject unsupported cluster strategy"), + Err(err) => assert_eq!(err.code(), Code::InvalidArgument), + } + } + #[tokio::test] async fn from_registry_reports_disabled_backend() { let result = RawTripleClient::from_registry("nacos://127.0.0.1:8848").await; diff --git a/dubbo/src/cluster/mod.rs b/dubbo/src/cluster/mod.rs index 1a20c160..cbd690be 100644 --- a/dubbo/src/cluster/mod.rs +++ b/dubbo/src/cluster/mod.rs @@ -15,30 +15,31 @@ * limitations under the License. */ +use futures_core::future::BoxFuture; use http::Request; +use tower::ServiceExt; use tower_service::Service; use crate::{ codegen::RpcInvocation, invoker::clone_body::CloneBody, param::Param, svc::NewService, }; -use self::failover::Failover; - -mod failover; - pub struct NewCluster { inner: N, // new loadbalancer service + strategy: ClusterStrategy, } pub struct Cluster { - inner: S, // failover service + inner: S, // loadbalancer service + strategy: ClusterStrategy, } impl NewCluster { - pub fn layer() -> impl tower_layer::Layer { - tower_layer::layer_fn(|inner: N| { + pub fn layer(strategy: ClusterStrategy) -> impl tower_layer::Layer { + tower_layer::layer_fn(move |inner: N| { NewCluster { inner, // new loadbalancer service + strategy: strategy.clone(), } }) } @@ -50,24 +51,47 @@ where // new loadbalancer service S: NewService, { - type Service = Cluster>; + type Service = Cluster; fn new_service(&self, target: T) -> Self::Service { Cluster { - inner: Failover::new(self.inner.new_service(target)), + inner: self.inner.new_service(target), + strategy: self.strategy.clone(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum ClusterStrategy { + Failfast, + #[default] + Failover, +} + +impl ClusterStrategy { + const DEFAULT_FAILOVER_ATTEMPTS: usize = 2; + + pub fn parse(strategy: &str) -> Option { + match strategy { + "failfast" => Some(Self::Failfast), + "failover" => Some(Self::Failover), + _ => None, } } } impl Service> for Cluster where - S: Service>, + S: Service> + Clone + Send + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, + S::Response: Send + 'static, { type Response = S::Response; type Error = S::Error; - type Future = S::Future; + type Future = BoxFuture<'static, Result>; fn poll_ready( &mut self, @@ -79,7 +103,50 @@ where fn call(&mut self, req: Request) -> Self::Future { let (parts, body) = req.into_parts(); let clone_body = CloneBody::new(body); - let req = Request::from_parts(parts, clone_body); - self.inner.call(req) + let replay_body = clone_body.clone(); + let method = parts.method; + let uri = parts.uri; + let version = parts.version; + let headers = parts.headers; + let extensions = parts.extensions; + let inner = self.inner.clone(); + let strategy = self.strategy.clone(); + + Box::pin(async move { + let make_request = |body, extensions| { + let mut req = Request::new(body); + *req.method_mut() = method.clone(); + *req.uri_mut() = uri.clone(); + *req.version_mut() = version; + *req.headers_mut() = headers.clone(); + if let Some(extensions) = extensions { + *req.extensions_mut() = extensions; + } + req + }; + + match strategy { + ClusterStrategy::Failfast => { + inner + .oneshot(make_request(clone_body, Some(extensions))) + .await + } + ClusterStrategy::Failover => { + let mut last_error = None; + let mut first_body = Some(clone_body); + let mut first_extensions = Some(extensions); + for _ in 0..ClusterStrategy::DEFAULT_FAILOVER_ATTEMPTS { + let body = first_body.take().unwrap_or_else(|| replay_body.clone()); + let extensions = first_extensions.take(); + match inner.clone().oneshot(make_request(body, extensions)).await { + Ok(response) => return Ok(response), + Err(err) => last_error = Some(err), + } + } + + Err(last_error.expect("failover attempts must be greater than zero")) + } + } + }) } } diff --git a/dubbo/src/triple/client/builder.rs b/dubbo/src/triple/client/builder.rs index 6b6d5bde..27155975 100644 --- a/dubbo/src/triple/client/builder.rs +++ b/dubbo/src/triple/client/builder.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use crate::{ - cluster::NewCluster, + cluster::{ClusterStrategy, NewCluster}, directory::NewCachedDirectory, extension, loadbalancer::{LoadBalanceStrategy, NewLoadBalancer}, @@ -46,6 +46,7 @@ pub struct ClientBuilder { registry_extension_url: Option, pub direct: bool, pub load_balance: LoadBalanceStrategy, + pub cluster: ClusterStrategy, } impl ClientBuilder { @@ -56,6 +57,7 @@ impl ClientBuilder { registry_extension_url: None, direct: false, load_balance: LoadBalanceStrategy::default(), + cluster: ClusterStrategy::default(), } } @@ -83,6 +85,7 @@ impl ClientBuilder { registry_extension_url: Some(registry_extension_url), direct: true, load_balance: LoadBalanceStrategy::default(), + cluster: ClusterStrategy::default(), } } @@ -125,6 +128,10 @@ impl ClientBuilder { } } + pub fn with_cluster(self, cluster: ClusterStrategy) -> Self { + Self { cluster, ..self } + } + pub fn build(mut self) -> ServiceMK { let registry = self .registry_extension_url @@ -132,7 +139,7 @@ impl ClientBuilder { .expect("registry must not be empty"); let mk_service = ServiceBuilder::new() - .layer(NewCluster::layer()) + .layer(NewCluster::layer(self.cluster)) .layer(NewLoadBalancer::layer(self.load_balance)) .layer(NewRoutes::layer()) .layer(NewCachedDirectory::layer()) From a208f614aec14514da6c3f55a23d902c6e98f559 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 03:17:13 +0800 Subject: [PATCH 10/23] feat: route providers by tag --- dubbo-rs-core/tests/raw_unary.rs | 78 +++++++++++++++++++++++++++++- dubbo/src/invoker/clone_invoker.rs | 16 +++++- dubbo/src/invoker/mod.rs | 6 +-- dubbo/src/route/mod.rs | 71 +++++++++++++++++++++++++-- 4 files changed, 162 insertions(+), 9 deletions(-) diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index 9943b3c5..2993934c 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -33,6 +33,7 @@ use tower_service::Service; #[derive(Clone)] struct SlowRawUnaryService { delay: Duration, + response_payload: Bytes, } impl Service> for SlowRawUnaryService { @@ -46,9 +47,9 @@ impl Service> for SlowRawUnaryService { fn call(&mut self, _req: http::Request) -> Self::Future { let delay = self.delay; + let response_payload = self.response_payload.clone(); Box::pin(async move { tokio::time::sleep(delay).await; - let response_payload = Bytes::from_static(b"\x0a\x0draw response"); let body = http_body::combinators::UnsyncBoxBody::new( hyper::Body::from(grpc_frame(response_payload)).map_err(|err| { dubbo::status::Status::new(dubbo::status::Code::Internal, err.to_string()) @@ -107,6 +108,57 @@ async fn raw_unary_uses_static_endpoint_list() { server_task.await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_routes_static_endpoints_by_tag() { + const SERVICE: &str = "grpc.examples.echo.TaggedEndpointEcho"; + let blue_addr = unused_local_addr(); + let green_addr = unused_local_addr(); + let (blue_shutdown_tx, blue_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let (green_shutdown_tx, green_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let blue_server_task = spawn_server_with_payload( + blue_addr, + blue_shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(0), + Bytes::from_static(b"\x0a\x04blue"), + ); + let green_server_task = spawn_server_with_payload( + green_addr, + green_shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(0), + Bytes::from_static(b"\x0a\x05green"), + ); + + wait_for_server(blue_addr).await; + wait_for_server(green_addr).await; + + let blue_endpoint = format!("http://{blue_addr}?interface={SERVICE}&tag=blue"); + let green_endpoint = format!("http://{green_addr}?interface={SERVICE}&tag=green"); + let mut client = + RawTripleClient::from_static_endpoints([green_endpoint.as_str(), blue_endpoint.as_str()]) + .unwrap(); + let response = client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new().insert("dubbo.tag", "blue"), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(10_000), + }) + .await + .unwrap(); + + assert_eq!(response.body, Bytes::from_static(b"\x0a\x04blue")); + + let _ = blue_shutdown_tx.send(()); + let _ = green_shutdown_tx.send(()); + blue_server_task.await.unwrap(); + green_server_task.await.unwrap(); +} + async fn raw_unary_timeout_returns_deadline_exceeded() { const SERVICE: &str = "grpc.examples.echo.TimeoutEcho"; let addr = unused_local_addr(); @@ -146,11 +198,33 @@ fn spawn_server( shutdown_rx: tokio::sync::oneshot::Receiver<()>, service_name: String, delay: Duration, +) -> tokio::task::JoinHandle<()> { + spawn_server_with_payload( + addr, + shutdown_rx, + service_name, + delay, + Bytes::from_static(b"\x0a\x0draw response"), + ) +} + +fn spawn_server_with_payload( + addr: SocketAddr, + shutdown_rx: tokio::sync::oneshot::Receiver<()>, + service_name: String, + delay: Duration, + response_payload: Bytes, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { DubboServer::new() .with_listener("tcp".to_string()) - .add_service(service_name, SlowRawUnaryService { delay }) + .add_service( + service_name, + SlowRawUnaryService { + delay, + response_payload, + }, + ) .serve_with_graceful(addr, async move { let _ = shutdown_rx.await; }) diff --git a/dubbo/src/invoker/clone_invoker.rs b/dubbo/src/invoker/clone_invoker.rs index 019d0ee9..708cd24b 100644 --- a/dubbo/src/invoker/clone_invoker.rs +++ b/dubbo/src/invoker/clone_invoker.rs @@ -16,7 +16,7 @@ */ use std::{mem, pin::Pin, task::Poll}; -use crate::{logger::tracing::debug, StdError}; +use crate::{logger::tracing::debug, StdError, Url}; use futures_core::{future::BoxFuture, ready, Future, TryFuture}; use futures_util::FutureExt; use pin_project::pin_project; @@ -173,6 +173,7 @@ where Inv::Future: Send, { inner: Buffer, http::Request>, + url: Option, rx: Receiver, poll: ReusableBoxFuture<'static, ObserveState>, polling: bool, @@ -194,11 +195,23 @@ where Self { inner: buffer, + url: None, rx, polling: false, poll: ReusableBoxFuture::new(futures::future::pending()), } } + + pub fn new_with_url(invoker: Inv, url: Url) -> Self { + Self { + url: Some(url), + ..Self::new(invoker) + } + } + + pub fn url(&self) -> Option<&Url> { + self.url.as_ref() + } } impl Service> for CloneInvoker @@ -262,6 +275,7 @@ where fn clone(&self) -> Self { Self { inner: self.inner.clone(), + url: self.url.clone(), rx: self.rx.clone(), polling: false, poll: ReusableBoxFuture::new(futures::future::pending()), diff --git a/dubbo/src/invoker/mod.rs b/dubbo/src/invoker/mod.rs index 1c87c0ec..9745d595 100644 --- a/dubbo/src/invoker/mod.rs +++ b/dubbo/src/invoker/mod.rs @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -use crate::{codegen::TripleInvoker, invoker::clone_invoker::CloneInvoker, svc::NewService}; +use crate::{codegen::TripleInvoker, invoker::clone_invoker::CloneInvoker, svc::NewService, Url}; pub mod clone_body; pub mod clone_invoker; @@ -27,7 +27,7 @@ impl NewService for NewInvoker { fn new_service(&self, url: String) -> Self::Service { // todo create another invoker by url protocol - let url = url.parse().unwrap(); - CloneInvoker::new(TripleInvoker::new(url)) + let url: Url = url.parse().unwrap(); + CloneInvoker::new_with_url(TripleInvoker::new(url.clone()), url) } } diff --git a/dubbo/src/route/mod.rs b/dubbo/src/route/mod.rs index b369d83a..804cd329 100644 --- a/dubbo/src/route/mod.rs +++ b/dubbo/src/route/mod.rs @@ -25,11 +25,17 @@ use tower_service::Service; use crate::{ codegen::{RpcInvocation, TripleInvoker}, + invocation::Metadata, invoker::clone_invoker::CloneInvoker, param::Param, svc::NewService, + Url, }; +const DUBBO_TAG_KEY: &str = "dubbo.tag"; +const TRIPLE_SERVICE_TAG_KEY: &str = "tri-service-tag"; +const TAG_KEY: &str = "tag"; + pub struct NewRoutes { inner: N, } @@ -161,8 +167,67 @@ where } fn call(&mut self, _: ()) -> Self::Future { - // some router operator - // if new_invokers changed, send new invokers to routes_rx after router operator - futures_util::future::ok(self.invokers.clone()) + futures_util::future::ok(route_by_tag( + self.invokers.clone(), + self.target.param().get_metadata(), + )) + } +} + +fn route_by_tag( + invokers: Vec>, + metadata: Metadata, +) -> Vec> { + let Some(request_tag) = request_tag(metadata) else { + return prefer_untagged(invokers); + }; + + let mut tagged = Vec::new(); + let mut untagged = Vec::new(); + for invoker in invokers.iter() { + match invoker.url().and_then(provider_tag) { + Some(provider_tag) if provider_tag == request_tag => tagged.push(invoker.clone()), + None => untagged.push(invoker.clone()), + _ => {} + } + } + + if !tagged.is_empty() { + return tagged; + } + if !untagged.is_empty() { + return untagged; } + + invokers +} + +fn prefer_untagged(invokers: Vec>) -> Vec> { + let untagged = invokers + .iter() + .filter(|invoker| invoker.url().and_then(provider_tag).is_none()) + .cloned() + .collect::>(); + + if untagged.is_empty() { + invokers + } else { + untagged + } +} + +fn request_tag(metadata: Metadata) -> Option { + let headers = metadata.into_headers(); + [DUBBO_TAG_KEY, TRIPLE_SERVICE_TAG_KEY, TAG_KEY] + .into_iter() + .find_map(|key| headers.get(key).and_then(|value| value.to_str().ok())) + .filter(|tag| !tag.is_empty()) + .map(ToOwned::to_owned) +} + +fn provider_tag(url: &Url) -> Option { + [DUBBO_TAG_KEY, TRIPLE_SERVICE_TAG_KEY, TAG_KEY] + .into_iter() + .find_map(|key| url.query_param_by_key(key)) + .filter(|tag| !tag.is_empty()) } From d8dfe6bd053e7b5de94760e18f107d0d45e4a0c7 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 03:21:08 +0800 Subject: [PATCH 11/23] feat: route providers by group and version --- dubbo-rs-core/tests/raw_unary.rs | 91 ++++++++++++++++++++++++++++++++ dubbo/src/loadbalancer/mod.rs | 7 +++ dubbo/src/route/mod.rs | 68 +++++++++++++++++++++--- 3 files changed, 159 insertions(+), 7 deletions(-) diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index 2993934c..f831cbcc 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -159,6 +159,97 @@ async fn raw_unary_routes_static_endpoints_by_tag() { green_server_task.await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_routes_static_endpoints_by_group_and_version() { + const SERVICE: &str = "grpc.examples.echo.GroupVersionEndpointEcho"; + let blue_addr = unused_local_addr(); + let green_addr = unused_local_addr(); + let (blue_shutdown_tx, blue_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let (green_shutdown_tx, green_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let blue_server_task = spawn_server_with_payload( + blue_addr, + blue_shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(0), + Bytes::from_static(b"\x0a\x04blue"), + ); + let green_server_task = spawn_server_with_payload( + green_addr, + green_shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(0), + Bytes::from_static(b"\x0a\x05green"), + ); + + wait_for_server(blue_addr).await; + wait_for_server(green_addr).await; + + let blue_endpoint = format!("http://{blue_addr}?interface={SERVICE}&group=blue&version=1.0.0"); + let green_endpoint = + format!("http://{green_addr}?interface={SERVICE}&group=green&version=1.0.0"); + let mut client = + RawTripleClient::from_static_endpoints([green_endpoint.as_str(), blue_endpoint.as_str()]) + .unwrap(); + let response = client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new() + .insert("tri-service-group", "blue") + .insert("tri-service-version", "1.0.0"), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(10_000), + }) + .await + .unwrap(); + + assert_eq!(response.body, Bytes::from_static(b"\x0a\x04blue")); + + let _ = blue_shutdown_tx.send(()); + let _ = green_shutdown_tx.send(()); + blue_server_task.await.unwrap(); + green_server_task.await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_errors_when_routing_metadata_matches_no_provider() { + const SERVICE: &str = "grpc.examples.echo.NoMatchedEndpointEcho"; + let addr = unused_local_addr(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let server_task = spawn_server( + addr, + shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(0), + ); + + wait_for_server(addr).await; + + let endpoint = format!("http://{addr}?interface={SERVICE}&group=green&version=1.0.0"); + let mut client = RawTripleClient::from_static(&endpoint); + let err = client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new() + .insert("tri-service-group", "blue") + .insert("tri-service-version", "1.0.0"), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(10_000), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::Internal); + + let _ = shutdown_tx.send(()); + server_task.await.unwrap(); +} + async fn raw_unary_timeout_returns_deadline_exceeded() { const SERVICE: &str = "grpc.examples.echo.TimeoutEcho"; let addr = unused_local_addr(); diff --git a/dubbo/src/loadbalancer/mod.rs b/dubbo/src/loadbalancer/mod.rs index 0855d9b9..b85fde82 100644 --- a/dubbo/src/loadbalancer/mod.rs +++ b/dubbo/src/loadbalancer/mod.rs @@ -37,6 +37,7 @@ use crate::{ loadbalancer::random::RandomLoadBalancer, param::Param, protocol::triple::triple_invoker::TripleInvoker, + status::DubboError, svc::NewService, StdError, }; @@ -116,6 +117,12 @@ where Err(e) => return Err(Into::::into(e)), Ok(routes) => routes, }; + if routes.is_empty() { + return Err(DubboError::new( + "no provider matched request routing metadata".to_string(), + ) + .into()); + } // let service_list: Vec<_> = routes // .into_iter() diff --git a/dubbo/src/route/mod.rs b/dubbo/src/route/mod.rs index 804cd329..45535de3 100644 --- a/dubbo/src/route/mod.rs +++ b/dubbo/src/route/mod.rs @@ -35,6 +35,10 @@ use crate::{ const DUBBO_TAG_KEY: &str = "dubbo.tag"; const TRIPLE_SERVICE_TAG_KEY: &str = "tri-service-tag"; const TAG_KEY: &str = "tag"; +const TRIPLE_SERVICE_GROUP_KEY: &str = "tri-service-group"; +const GROUP_KEY: &str = "group"; +const TRIPLE_SERVICE_VERSION_KEY: &str = "tri-service-version"; +const VERSION_KEY: &str = "version"; pub struct NewRoutes { inner: N, @@ -167,13 +171,50 @@ where } fn call(&mut self, _: ()) -> Self::Future { - futures_util::future::ok(route_by_tag( + futures_util::future::ok(route_invokers( self.invokers.clone(), self.target.param().get_metadata(), )) } } +fn route_invokers( + invokers: Vec>, + metadata: Metadata, +) -> Vec> { + let invokers = route_by_service_metadata(invokers, metadata.clone()); + route_by_tag(invokers, metadata) +} + +fn route_by_service_metadata( + invokers: Vec>, + metadata: Metadata, +) -> Vec> { + let group = metadata_value(metadata.clone(), &[TRIPLE_SERVICE_GROUP_KEY, GROUP_KEY]); + let version = metadata_value(metadata, &[TRIPLE_SERVICE_VERSION_KEY, VERSION_KEY]); + + if group.is_none() && version.is_none() { + return invokers; + } + + invokers + .into_iter() + .filter(|invoker| { + invoker.url().is_some_and(|url| { + matches_provider_param( + url, + &[TRIPLE_SERVICE_GROUP_KEY, GROUP_KEY], + group.as_deref(), + ) && matches_provider_param( + url, + &[TRIPLE_SERVICE_VERSION_KEY, VERSION_KEY], + version.as_deref(), + ) + }) + }) + .collect() +} + fn route_by_tag( invokers: Vec>, metadata: Metadata, @@ -217,12 +258,7 @@ fn prefer_untagged(invokers: Vec>) -> Vec Option { - let headers = metadata.into_headers(); - [DUBBO_TAG_KEY, TRIPLE_SERVICE_TAG_KEY, TAG_KEY] - .into_iter() - .find_map(|key| headers.get(key).and_then(|value| value.to_str().ok())) - .filter(|tag| !tag.is_empty()) - .map(ToOwned::to_owned) + metadata_value(metadata, &[DUBBO_TAG_KEY, TRIPLE_SERVICE_TAG_KEY, TAG_KEY]) } fn provider_tag(url: &Url) -> Option { @@ -231,3 +267,21 @@ fn provider_tag(url: &Url) -> Option { .find_map(|key| url.query_param_by_key(key)) .filter(|tag| !tag.is_empty()) } + +fn metadata_value(metadata: Metadata, keys: &[&str]) -> Option { + let headers = metadata.into_headers(); + keys.iter() + .find_map(|key| headers.get(*key).and_then(|value| value.to_str().ok())) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn matches_provider_param(url: &Url, keys: &[&str], expected: Option<&str>) -> bool { + let Some(expected) = expected else { + return true; + }; + + keys.iter() + .find_map(|key| url.query_param_by_key(key)) + .is_some_and(|actual| actual == expected) +} From c4d4bf37b839ab43f3ae42a91d1ad9db4682d8d3 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 03:25:19 +0800 Subject: [PATCH 12/23] fix: preserve routing status errors --- dubbo-rs-core/tests/raw_unary.rs | 2 +- dubbo/src/loadbalancer/mod.rs | 7 ------- dubbo/src/route/mod.rs | 28 ++++++++++++++++++++-------- dubbo/src/status.rs | 5 ++++- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index f831cbcc..53689b00 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -244,7 +244,7 @@ async fn raw_unary_errors_when_routing_metadata_matches_no_provider() { .await .unwrap_err(); - assert_eq!(err.code(), Code::Internal); + assert_eq!(err.code(), Code::Unavailable); let _ = shutdown_tx.send(()); server_task.await.unwrap(); diff --git a/dubbo/src/loadbalancer/mod.rs b/dubbo/src/loadbalancer/mod.rs index b85fde82..0855d9b9 100644 --- a/dubbo/src/loadbalancer/mod.rs +++ b/dubbo/src/loadbalancer/mod.rs @@ -37,7 +37,6 @@ use crate::{ loadbalancer::random::RandomLoadBalancer, param::Param, protocol::triple::triple_invoker::TripleInvoker, - status::DubboError, svc::NewService, StdError, }; @@ -117,12 +116,6 @@ where Err(e) => return Err(Into::::into(e)), Ok(routes) => routes, }; - if routes.is_empty() { - return Err(DubboError::new( - "no provider matched request routing metadata".to_string(), - ) - .into()); - } // let service_list: Vec<_> = routes // .into_iter() diff --git a/dubbo/src/route/mod.rs b/dubbo/src/route/mod.rs index 45535de3..d6a4ec7b 100644 --- a/dubbo/src/route/mod.rs +++ b/dubbo/src/route/mod.rs @@ -28,6 +28,7 @@ use crate::{ invocation::Metadata, invoker::clone_invoker::CloneInvoker, param::Param, + status::{Code, Status}, svc::NewService, Url, }; @@ -171,7 +172,7 @@ where } fn call(&mut self, _: ()) -> Self::Future { - futures_util::future::ok(route_invokers( + futures_util::future::ready(route_invokers( self.invokers.clone(), self.target.param().get_metadata(), )) @@ -181,23 +182,24 @@ where fn route_invokers( invokers: Vec>, metadata: Metadata, -) -> Vec> { - let invokers = route_by_service_metadata(invokers, metadata.clone()); - route_by_tag(invokers, metadata) +) -> Result>, StdError> { + let invokers = route_by_service_metadata(invokers, metadata.clone())?; + Ok(route_by_tag(invokers, metadata)) } fn route_by_service_metadata( invokers: Vec>, metadata: Metadata, -) -> Vec> { +) -> Result>, StdError> { let group = metadata_value(metadata.clone(), &[TRIPLE_SERVICE_GROUP_KEY, GROUP_KEY]); let version = metadata_value(metadata, &[TRIPLE_SERVICE_VERSION_KEY, VERSION_KEY]); if group.is_none() && version.is_none() { - return invokers; + return Ok(invokers); } - invokers + let has_candidates = !invokers.is_empty(); + let filtered = invokers .into_iter() .filter(|invoker| { invoker.url().is_some_and(|url| { @@ -212,7 +214,17 @@ fn route_by_service_metadata( ) }) }) - .collect() + .collect::>(); + + if has_candidates && filtered.is_empty() { + return Err(Status::new( + Code::Unavailable, + "no provider matched request routing metadata".to_string(), + ) + .into()); + } + + Ok(filtered) } fn route_by_tag( diff --git a/dubbo/src/status.rs b/dubbo/src/status.rs index 7258b481..94f52880 100644 --- a/dubbo/src/status.rs +++ b/dubbo/src/status.rs @@ -274,7 +274,10 @@ impl Status { } pub fn from_error(err: crate::Error) -> Self { - Status::new(Code::Internal, err.to_string()) + match err.downcast::() { + Ok(status) => *status, + Err(err) => Status::new(Code::Internal, err.to_string()), + } } pub fn code(&self) -> Code { From 3abfa764b10b6367869f34a67f88aabe635b0892 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 09:14:18 +0800 Subject: [PATCH 13/23] feat: support default raw unary timeout --- dubbo-rs-core/src/lib.rs | 6 ++- dubbo-rs-core/tests/raw_unary.rs | 86 +++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index aff10cff..8cc9b027 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -37,6 +37,7 @@ use dubbo_registry_zookeeper::ZookeeperRegistry; /// protobuf message shape and use this facade for Dubbo transport and governance. pub struct RawTripleClient { inner: TripleClient, + default_timeout_ms: Option, } impl RawTripleClient { @@ -79,6 +80,7 @@ impl RawTripleClient { options.apply(ClientBuilder::from_static_urls(endpoints).with_direct(true))?; Ok(Self { inner: TripleClient::new(builder), + default_timeout_ms: options.timeout_ms, }) } @@ -102,6 +104,7 @@ impl RawTripleClient { let builder = options.apply(ClientBuilder::new().with_registry(registry_url))?; Ok(Self { inner: TripleClient::new(builder), + default_timeout_ms: options.timeout_ms, }) } @@ -118,7 +121,7 @@ impl RawTripleClient { let invocation = RpcInvocation::default() .with_service_unique_name(request.service) .with_method_name(request.method); - let timeout_ms = request.timeout_ms; + let timeout_ms = request.timeout_ms.or(self.default_timeout_ms); let call = self.inner.raw_unary( Request::from_parts(request.metadata.into(), request.body), path, @@ -147,6 +150,7 @@ impl RawTripleClient { #[derive(Debug, Clone, Default)] pub struct RawTripleClientOptions { + pub timeout_ms: Option, pub load_balance: Option, pub cluster: Option, } diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index 53689b00..80e79abd 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -26,7 +26,7 @@ use std::{ use bytes::{BufMut, Bytes, BytesMut}; use dubbo::{status::Code, triple::transport::DubboServer, BoxBody}; -use dubbo_rs_core::{RawMetadata, RawTripleClient, RawUnaryRequest}; +use dubbo_rs_core::{RawMetadata, RawTripleClient, RawTripleClientOptions, RawUnaryRequest}; use http_body::Body; use tower_service::Service; @@ -284,6 +284,90 @@ async fn raw_unary_timeout_returns_deadline_exceeded() { server_task.await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_uses_client_default_timeout() { + const SERVICE: &str = "grpc.examples.echo.DefaultTimeoutEcho"; + let addr = unused_local_addr(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let server_task = spawn_server( + addr, + shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(100), + ); + + wait_for_server(addr).await; + + let endpoint = format!("http://{addr}?interface={SERVICE}"); + let mut client = RawTripleClient::from_static_endpoints_with_options( + [endpoint.as_str()], + RawTripleClientOptions { + timeout_ms: Some(10), + ..RawTripleClientOptions::default() + }, + ) + .unwrap(); + let err = client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new(), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: None, + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::DeadlineExceeded); + + let _ = shutdown_tx.send(()); + server_task.await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_request_timeout_overrides_client_default_timeout() { + const SERVICE: &str = "grpc.examples.echo.OverrideTimeoutEcho"; + let addr = unused_local_addr(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let server_task = spawn_server( + addr, + shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(20), + ); + + wait_for_server(addr).await; + + let endpoint = format!("http://{addr}?interface={SERVICE}"); + let mut client = RawTripleClient::from_static_endpoints_with_options( + [endpoint.as_str()], + RawTripleClientOptions { + timeout_ms: Some(1), + ..RawTripleClientOptions::default() + }, + ) + .unwrap(); + let response = client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new(), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(10_000), + }) + .await + .unwrap(); + + assert_eq!(response.body, Bytes::from_static(b"\x0a\x0draw response")); + + let _ = shutdown_tx.send(()); + server_task.await.unwrap(); +} + fn spawn_server( addr: SocketAddr, shutdown_rx: tokio::sync::oneshot::Receiver<()>, From 0169702dfa8f528c2b5c8fce83e17172ea89f2fa Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 09:17:27 +0800 Subject: [PATCH 14/23] feat: expose raw status details --- dubbo-rs-core/src/lib.rs | 3 ++- dubbo/src/status.rs | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index 8cc9b027..1702ae58 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -23,7 +23,6 @@ use dubbo::{ codegen::{ClientBuilder, Request, RpcInvocation, TripleClient}, invocation::Metadata, loadbalancer::LoadBalanceStrategy, - status::{Code, Status}, Url, }; #[cfg(feature = "registry-nacos")] @@ -31,6 +30,8 @@ use dubbo_registry_nacos::NacosRegistry; #[cfg(feature = "registry-zookeeper")] use dubbo_registry_zookeeper::ZookeeperRegistry; +pub use dubbo::status::{Code, Status}; + /// A stable raw Triple client facade for embedding Dubbo Rust in other runtimes. /// /// This is intentionally byte-oriented. Hosts such as Node.js should own their diff --git a/dubbo/src/status.rs b/dubbo/src/status.rs index 94f52880..b6e1c6c0 100644 --- a/dubbo/src/status.rs +++ b/dubbo/src/status.rs @@ -284,6 +284,10 @@ impl Status { self.code } + pub fn message(&self) -> &str { + &self.message + } + pub fn to_http(&self) -> http::Response { let (mut parts, _) = http::Response::new(()).into_parts(); From 48957e3898ca8d2114259733256f534b8ab3274d Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 09:22:30 +0800 Subject: [PATCH 15/23] docs: document dubbo rs core crate --- dubbo-rs-core/Cargo.toml | 3 ++ dubbo-rs-core/README.md | 67 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 dubbo-rs-core/README.md diff --git a/dubbo-rs-core/Cargo.toml b/dubbo-rs-core/Cargo.toml index b78f5414..a9197d0e 100644 --- a/dubbo-rs-core/Cargo.toml +++ b/dubbo-rs-core/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" license = "Apache-2.0" description = "Stable core facade for embedding Dubbo Rust in other runtimes" repository = "https://github.com/apache/dubbo-rust.git" +readme = "README.md" +keywords = ["dubbo", "triple", "rpc", "napi"] +categories = ["network-programming", "api-bindings"] [dependencies] bytes.workspace = true diff --git a/dubbo-rs-core/README.md b/dubbo-rs-core/README.md new file mode 100644 index 00000000..978caf01 --- /dev/null +++ b/dubbo-rs-core/README.md @@ -0,0 +1,67 @@ +# dubbo-rs-core + +`dubbo-rs-core` is a small, byte-oriented facade for embedding Dubbo Rust in +other runtimes. + +The crate is intentionally lower level than the generated Rust client APIs. It +keeps the host language in control of service descriptors, protobuf +serialization, and public API shape, while Rust owns Dubbo transport and +governance behavior such as Triple calls, registry discovery, load balancing, +cluster strategy, routing, and timeouts. + +This boundary is useful for packages such as `dubbo-js`, where JavaScript should +keep the user-facing client API while a N-API addon reuses the Rust core. + +## Current Surface + +- Raw unary Triple requests and responses. +- Static provider endpoints. +- Registry-backed clients behind optional features. +- Request-level and client-default unary timeouts. +- Load balancing: `random`, `round_robin`, and `p2c`. +- Cluster strategy: `failfast` and `failover`. +- Metadata routing for tag, group, and version. +- Stable status code and message access for host-language error mapping. + +## Features + +- `registry-nacos`: enable Nacos registry support. +- `registry-zookeeper`: enable Zookeeper registry support. +- `registry`: enable all registry backends currently wired into this crate. + +## Example + +```rust +use bytes::Bytes; +use dubbo_rs_core::{ + RawMetadata, RawTripleClient, RawTripleClientOptions, RawUnaryRequest, +}; + +# async fn call() -> Result<(), Box> { +let mut client = RawTripleClient::from_static_endpoints_with_options( + ["http://127.0.0.1:50051?interface=example.EchoService"], + RawTripleClientOptions { + timeout_ms: Some(3_000), + load_balance: Some("round_robin".to_string()), + cluster: Some("failfast".to_string()), + }, +)?; + +let response = client + .unary(RawUnaryRequest { + service: "example.EchoService".to_string(), + method: "Echo".to_string(), + path: "/example.EchoService/Echo".to_string(), + metadata: RawMetadata::new().insert("tri-service-group", "default"), + body: Bytes::from_static(b"\x0a\x05hello"), + timeout_ms: None, + }) + .await?; + +let _bytes = response.body; +# Ok(()) +# } +``` + +The request and response bodies are protobuf bytes. Host runtimes should encode +and decode messages using their own protobuf toolchain. From 04b999d8c96f743180b6ec5db126f4b0643bfe56 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 09:35:41 +0800 Subject: [PATCH 16/23] feat: support default raw metadata --- dubbo-rs-core/src/lib.rs | 14 ++++- dubbo-rs-core/tests/raw_unary.rs | 102 +++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index 1702ae58..bb10d18c 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -39,6 +39,7 @@ pub use dubbo::status::{Code, Status}; pub struct RawTripleClient { inner: TripleClient, default_timeout_ms: Option, + default_metadata: RawMetadata, } impl RawTripleClient { @@ -82,6 +83,7 @@ impl RawTripleClient { Ok(Self { inner: TripleClient::new(builder), default_timeout_ms: options.timeout_ms, + default_metadata: options.default_metadata, }) } @@ -106,6 +108,7 @@ impl RawTripleClient { Ok(Self { inner: TripleClient::new(builder), default_timeout_ms: options.timeout_ms, + default_metadata: options.default_metadata, }) } @@ -124,7 +127,10 @@ impl RawTripleClient { .with_method_name(request.method); let timeout_ms = request.timeout_ms.or(self.default_timeout_ms); let call = self.inner.raw_unary( - Request::from_parts(request.metadata.into(), request.body), + Request::from_parts( + self.default_metadata.clone().merge(request.metadata).into(), + request.body, + ), path, invocation, ); @@ -154,6 +160,7 @@ pub struct RawTripleClientOptions { pub timeout_ms: Option, pub load_balance: Option, pub cluster: Option, + pub default_metadata: RawMetadata, } impl RawTripleClientOptions { @@ -248,6 +255,11 @@ impl RawMetadata { self.entries.push((key.into(), value.into())); self } + + pub fn merge(mut self, other: RawMetadata) -> Self { + self.entries.extend(other.entries); + self + } } impl From for Metadata { diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index 80e79abd..e9765ffc 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -36,6 +36,9 @@ struct SlowRawUnaryService { response_payload: Bytes, } +#[derive(Clone)] +struct MetadataEchoService; + impl Service> for SlowRawUnaryService { type Response = http::Response; type Error = Infallible; @@ -65,6 +68,48 @@ impl Service> for SlowRawUnaryService { } } +impl Service> for MetadataEchoService { + type Response = http::Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + let default_header = req + .headers() + .get("x-client-app") + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + let call_header = req + .headers() + .get("x-call-id") + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + + Box::pin(async move { + let body = http_body::combinators::UnsyncBoxBody::new( + hyper::Body::from(grpc_frame(protobuf_string(format!( + "{default_header}:{call_header}" + )))) + .map_err(|err| { + dubbo::status::Status::new(dubbo::status::Code::Internal, err.to_string()) + }), + ); + + Ok(http::Response::builder() + .status(http::StatusCode::OK) + .header("content-type", "application/grpc+proto") + .body(body) + .unwrap()) + }) + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_uses_static_endpoint_list_and_maps_timeout() { raw_unary_uses_static_endpoint_list().await; @@ -108,6 +153,54 @@ async fn raw_unary_uses_static_endpoint_list() { server_task.await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_applies_default_metadata_and_allows_request_override() { + const SERVICE: &str = "grpc.examples.echo.DefaultMetadataEcho"; + let addr = unused_local_addr(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let server_task = tokio::spawn(async move { + DubboServer::new() + .with_listener("tcp".to_string()) + .add_service(SERVICE.to_string(), MetadataEchoService) + .serve_with_graceful(addr, async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + wait_for_server(addr).await; + + let endpoint = format!("http://{addr}?interface={SERVICE}"); + let mut client = RawTripleClient::from_static_endpoints_with_options( + [endpoint.as_str()], + RawTripleClientOptions { + default_metadata: RawMetadata::new() + .insert("x-client-app", "default-app") + .insert("x-call-id", "default-call"), + ..RawTripleClientOptions::default() + }, + ) + .unwrap(); + let response = client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new().insert("x-call-id", "request-call"), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(10_000), + }) + .await + .unwrap(); + + assert_eq!(response.body, protobuf_string("default-app:request-call")); + + let _ = shutdown_tx.send(()); + server_task.await.unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_routes_static_endpoints_by_tag() { const SERVICE: &str = "grpc.examples.echo.TaggedEndpointEcho"; @@ -429,6 +522,15 @@ fn grpc_frame(payload: Bytes) -> Bytes { frame.freeze() } +fn protobuf_string(value: impl AsRef) -> Bytes { + let value = value.as_ref().as_bytes(); + let mut message = BytesMut::with_capacity(2 + value.len()); + message.put_u8(0x0a); + message.put_u8(value.len() as u8); + message.extend_from_slice(value); + message.freeze() +} + fn unused_local_addr() -> SocketAddr { std::net::TcpListener::bind("127.0.0.1:0") .unwrap() From a03e9cfed9b95cd91431fb5d8045d7909b582351 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 09:47:26 +0800 Subject: [PATCH 17/23] feat: configure failover retries --- dubbo-rs-core/README.md | 4 +- dubbo-rs-core/src/lib.rs | 17 ++++ dubbo/src/cluster/mod.rs | 113 +++++++++++++++++++++-- dubbo/src/triple/client/builder.rs | 7 ++ dubbo/src/triple/transport/connection.rs | 4 +- 5 files changed, 136 insertions(+), 9 deletions(-) diff --git a/dubbo-rs-core/README.md b/dubbo-rs-core/README.md index 978caf01..e54ada6f 100644 --- a/dubbo-rs-core/README.md +++ b/dubbo-rs-core/README.md @@ -19,7 +19,7 @@ keep the user-facing client API while a N-API addon reuses the Rust core. - Registry-backed clients behind optional features. - Request-level and client-default unary timeouts. - Load balancing: `random`, `round_robin`, and `p2c`. -- Cluster strategy: `failfast` and `failover`. +- Cluster strategy: `failfast` and `failover`, with configurable failover retries. - Metadata routing for tag, group, and version. - Stable status code and message access for host-language error mapping. @@ -44,6 +44,8 @@ let mut client = RawTripleClient::from_static_endpoints_with_options( timeout_ms: Some(3_000), load_balance: Some("round_robin".to_string()), cluster: Some("failfast".to_string()), + failover_retries: None, + ..RawTripleClientOptions::default() }, )?; diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index bb10d18c..cb5d0f1a 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -160,6 +160,7 @@ pub struct RawTripleClientOptions { pub timeout_ms: Option, pub load_balance: Option, pub cluster: Option, + pub failover_retries: Option, pub default_metadata: RawMetadata, } @@ -193,6 +194,22 @@ impl RawTripleClientOptions { None => builder, }; + let builder = match self.failover_retries { + Some(retries) => { + let attempts = usize::try_from(retries) + .ok() + .and_then(|retries| retries.checked_add(1)) + .ok_or_else(|| { + Status::new( + Code::InvalidArgument, + format!("unsupported failover retries {retries}"), + ) + })?; + builder.with_failover_attempts(attempts) + } + None => builder, + }; + Ok(builder) } } diff --git a/dubbo/src/cluster/mod.rs b/dubbo/src/cluster/mod.rs index cbd690be..15c3ac53 100644 --- a/dubbo/src/cluster/mod.rs +++ b/dubbo/src/cluster/mod.rs @@ -61,23 +61,39 @@ where } } -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum ClusterStrategy { Failfast, - #[default] - Failover, + Failover { attempts: usize }, +} + +impl Default for ClusterStrategy { + fn default() -> Self { + Self::Failover { + attempts: Self::DEFAULT_FAILOVER_ATTEMPTS, + } + } } impl ClusterStrategy { - const DEFAULT_FAILOVER_ATTEMPTS: usize = 2; + pub const DEFAULT_FAILOVER_ATTEMPTS: usize = 2; pub fn parse(strategy: &str) -> Option { match strategy { "failfast" => Some(Self::Failfast), - "failover" => Some(Self::Failover), + "failover" => Some(Self::default()), _ => None, } } + + pub fn with_failover_attempts(self, attempts: usize) -> Self { + match self { + Self::Failover { .. } => Self::Failover { + attempts: attempts.max(1), + }, + Self::Failfast => Self::Failfast, + } + } } impl Service> for Cluster @@ -131,11 +147,11 @@ where .oneshot(make_request(clone_body, Some(extensions))) .await } - ClusterStrategy::Failover => { + ClusterStrategy::Failover { attempts } => { let mut last_error = None; let mut first_body = Some(clone_body); let mut first_extensions = Some(extensions); - for _ in 0..ClusterStrategy::DEFAULT_FAILOVER_ATTEMPTS { + for _ in 0..attempts { let body = first_body.take().unwrap_or_else(|| replay_body.clone()); let extensions = first_extensions.take(); match inner.clone().oneshot(make_request(body, extensions)).await { @@ -150,3 +166,86 @@ where }) } } + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, + }; + + use crate::invoker::clone_body::CloneBody; + use http::Request; + use tower_service::Service; + + use super::{Cluster, ClusterStrategy}; + + #[derive(Clone)] + struct FailsBeforeSuccess { + calls: Arc, + failures: usize, + } + + impl Service> for FailsBeforeSuccess { + type Response = http::Response<()>; + type Error = crate::Error; + type Future = std::future::Ready>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: Request) -> Self::Future { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + if call < self.failures { + std::future::ready(Err(Box::new(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "provider unavailable", + )))) + } else { + std::future::ready(Ok(http::Response::new(()))) + } + } + } + + #[tokio::test] + async fn failover_honors_configured_attempts() { + let calls = Arc::new(AtomicUsize::new(0)); + let mut cluster = Cluster { + inner: FailsBeforeSuccess { + calls: Arc::clone(&calls), + failures: 1, + }, + strategy: ClusterStrategy::Failover { attempts: 2 }, + }; + + cluster + .call(Request::new(hyper::Body::empty())) + .await + .unwrap(); + + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn failover_returns_last_error_after_attempts_are_exhausted() { + let calls = Arc::new(AtomicUsize::new(0)); + let mut cluster = Cluster { + inner: FailsBeforeSuccess { + calls: Arc::clone(&calls), + failures: 2, + }, + strategy: ClusterStrategy::Failover { attempts: 1 }, + }; + + cluster + .call(Request::new(hyper::Body::empty())) + .await + .unwrap_err(); + + assert_eq!(calls.load(Ordering::SeqCst), 1); + } +} diff --git a/dubbo/src/triple/client/builder.rs b/dubbo/src/triple/client/builder.rs index 27155975..0876fa0c 100644 --- a/dubbo/src/triple/client/builder.rs +++ b/dubbo/src/triple/client/builder.rs @@ -132,6 +132,13 @@ impl ClientBuilder { Self { cluster, ..self } } + pub fn with_failover_attempts(self, attempts: usize) -> Self { + Self { + cluster: self.cluster.with_failover_attempts(attempts), + ..self + } + } + pub fn build(mut self) -> ServiceMK { let registry = self .registry_extension_url diff --git a/dubbo/src/triple/transport/connection.rs b/dubbo/src/triple/transport/connection.rs index 060a24ee..c1a8cc57 100644 --- a/dubbo/src/triple/transport/connection.rs +++ b/dubbo/src/triple/transport/connection.rs @@ -101,7 +101,9 @@ impl Service> for Connection { let uri = self.host.clone(); let call_fut = connect.call(uri); let fut = async move { - let mut con = call_fut.await.unwrap(); + let mut con = call_fut + .await + .map_err(|err| -> crate::Error { Box::new(err) })?; con.call(req) .await .map_err(|err| err.into()) From 22b004c15b4a1f969309638ae142cf9904b6c945 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 09:56:31 +0800 Subject: [PATCH 18/23] feat: configure raw triple compression --- dubbo-rs-core/README.md | 2 + dubbo-rs-core/src/lib.rs | 29 ++++++ dubbo-rs-core/tests/raw_unary.rs | 97 +++++++++++++++++++++ dubbo/src/protocol/triple/triple_invoker.rs | 16 ---- dubbo/src/triple/client/builder.rs | 11 +++ dubbo/src/triple/client/triple.rs | 15 +++- 6 files changed, 153 insertions(+), 17 deletions(-) diff --git a/dubbo-rs-core/README.md b/dubbo-rs-core/README.md index e54ada6f..c846a8ca 100644 --- a/dubbo-rs-core/README.md +++ b/dubbo-rs-core/README.md @@ -20,6 +20,7 @@ keep the user-facing client API while a N-API addon reuses the Rust core. - Request-level and client-default unary timeouts. - Load balancing: `random`, `round_robin`, and `p2c`. - Cluster strategy: `failfast` and `failover`, with configurable failover retries. +- Compression: `gzip` or `identity`. - Metadata routing for tag, group, and version. - Stable status code and message access for host-language error mapping. @@ -45,6 +46,7 @@ let mut client = RawTripleClient::from_static_endpoints_with_options( load_balance: Some("round_robin".to_string()), cluster: Some("failfast".to_string()), failover_retries: None, + compression: Some("gzip".to_string()), ..RawTripleClientOptions::default() }, )?; diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index cb5d0f1a..dd35d3c5 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -23,6 +23,7 @@ use dubbo::{ codegen::{ClientBuilder, Request, RpcInvocation, TripleClient}, invocation::Metadata, loadbalancer::LoadBalanceStrategy, + triple::compression::CompressionEncoding, Url, }; #[cfg(feature = "registry-nacos")] @@ -161,6 +162,7 @@ pub struct RawTripleClientOptions { pub load_balance: Option, pub cluster: Option, pub failover_retries: Option, + pub compression: Option, pub default_metadata: RawMetadata, } @@ -210,6 +212,18 @@ impl RawTripleClientOptions { None => builder, }; + let builder = match self.compression.as_deref() { + Some("gzip") => builder.with_compression(Some(CompressionEncoding::Gzip)), + Some("identity") | Some("none") => builder.with_compression(None), + Some(compression) => { + return Err(Status::new( + Code::InvalidArgument, + format!("unsupported compression {compression}; expected gzip or identity"), + )); + } + None => builder, + }; + Ok(builder) } } @@ -344,6 +358,21 @@ mod tests { } } + #[test] + fn static_client_rejects_unknown_compression() { + let result = RawTripleClient::from_static_endpoints_with_options( + ["http://127.0.0.1:50051?interface=example.Echo"], + RawTripleClientOptions { + compression: Some("brotli".to_string()), + ..RawTripleClientOptions::default() + }, + ); + match result { + Ok(_) => panic!("client should reject unsupported compression"), + Err(err) => assert_eq!(err.code(), Code::InvalidArgument), + } + } + #[tokio::test] async fn from_registry_reports_disabled_backend() { let result = RawTripleClient::from_registry("nacos://127.0.0.1:8848").await; diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index e9765ffc..87a367c1 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -39,6 +39,9 @@ struct SlowRawUnaryService { #[derive(Clone)] struct MetadataEchoService; +#[derive(Clone)] +struct CompressionEchoService; + impl Service> for SlowRawUnaryService { type Response = http::Response; type Error = Infallible; @@ -110,6 +113,39 @@ impl Service> for MetadataEchoService { } } +impl Service> for CompressionEchoService { + type Response = http::Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + let compression = req + .headers() + .get("grpc-encoding") + .and_then(|value| value.to_str().ok()) + .unwrap_or("identity") + .to_string(); + + Box::pin(async move { + let body = http_body::combinators::UnsyncBoxBody::new( + hyper::Body::from(grpc_frame(protobuf_string(compression))).map_err(|err| { + dubbo::status::Status::new(dubbo::status::Code::Internal, err.to_string()) + }), + ); + + Ok(http::Response::builder() + .status(http::StatusCode::OK) + .header("content-type", "application/grpc+proto") + .body(body) + .unwrap()) + }) + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_uses_static_endpoint_list_and_maps_timeout() { raw_unary_uses_static_endpoint_list().await; @@ -201,6 +237,67 @@ async fn raw_unary_applies_default_metadata_and_allows_request_override() { server_task.await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_configures_compression() { + const SERVICE: &str = "grpc.examples.echo.CompressionEcho"; + let addr = unused_local_addr(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let server_task = tokio::spawn(async move { + DubboServer::new() + .with_listener("tcp".to_string()) + .add_service(SERVICE.to_string(), CompressionEchoService) + .serve_with_graceful(addr, async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + wait_for_server(addr).await; + + let endpoint = format!("http://{addr}?interface={SERVICE}"); + let mut gzip_client = RawTripleClient::from_static(&endpoint); + let gzip_response = gzip_client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new(), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(10_000), + }) + .await + .unwrap(); + + assert_eq!(gzip_response.body, protobuf_string("gzip")); + + let mut identity_client = RawTripleClient::from_static_endpoints_with_options( + [endpoint.as_str()], + RawTripleClientOptions { + compression: Some("identity".to_string()), + ..RawTripleClientOptions::default() + }, + ) + .unwrap(); + let identity_response = identity_client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new(), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(10_000), + }) + .await + .unwrap(); + + assert_eq!(identity_response.body, protobuf_string("identity")); + + let _ = shutdown_tx.send(()); + server_task.await.unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_routes_static_endpoints_by_tag() { const SERVICE: &str = "grpc.examples.echo.TaggedEndpointEcho"; diff --git a/dubbo/src/protocol/triple/triple_invoker.rs b/dubbo/src/protocol/triple/triple_invoker.rs index b80c18d7..f21e62cf 100644 --- a/dubbo/src/protocol/triple/triple_invoker.rs +++ b/dubbo/src/protocol/triple/triple_invoker.rs @@ -118,22 +118,6 @@ impl TripleInvoker { "tri-unit-info", HeaderValue::from_static("dubbo-rust/0.1.0"), ); - // if let Some(_encoding) = self.send_compression_encoding { - - // } - - insert_default_header( - req.headers_mut(), - "grpc-encoding", - http::HeaderValue::from_static("gzip"), - ); - - insert_default_header( - req.headers_mut(), - "grpc-accept-encoding", - http::HeaderValue::from_static("gzip"), - ); - // // const ( // // TripleContentType = "application/grpc+proto" // // TripleUserAgent = "grpc-go/1.35.0-dev" diff --git a/dubbo/src/triple/client/builder.rs b/dubbo/src/triple/client/builder.rs index 0876fa0c..ab98ffc6 100644 --- a/dubbo/src/triple/client/builder.rs +++ b/dubbo/src/triple/client/builder.rs @@ -23,6 +23,7 @@ use crate::{ extension, loadbalancer::{LoadBalanceStrategy, NewLoadBalancer}, route::NewRoutes, + triple::compression::CompressionEncoding, utils::boxed_clone::BoxCloneService, }; @@ -47,6 +48,7 @@ pub struct ClientBuilder { pub direct: bool, pub load_balance: LoadBalanceStrategy, pub cluster: ClusterStrategy, + pub send_compression_encoding: Option, } impl ClientBuilder { @@ -58,6 +60,7 @@ impl ClientBuilder { direct: false, load_balance: LoadBalanceStrategy::default(), cluster: ClusterStrategy::default(), + send_compression_encoding: Some(CompressionEncoding::Gzip), } } @@ -86,6 +89,7 @@ impl ClientBuilder { direct: true, load_balance: LoadBalanceStrategy::default(), cluster: ClusterStrategy::default(), + send_compression_encoding: Some(CompressionEncoding::Gzip), } } @@ -139,6 +143,13 @@ impl ClientBuilder { } } + pub fn with_compression(self, compression: Option) -> Self { + Self { + send_compression_encoding: compression, + ..self + } + } + pub fn build(mut self) -> ServiceMK { let registry = self .registry_extension_url diff --git a/dubbo/src/triple/client/triple.rs b/dubbo/src/triple/client/triple.rs index d44c51d8..5c4ade5f 100644 --- a/dubbo/src/triple/client/triple.rs +++ b/dubbo/src/triple/client/triple.rs @@ -57,8 +57,9 @@ impl TripleClient { } pub fn new(builder: ClientBuilder) -> Self { + let send_compression_encoding = builder.send_compression_encoding; TripleClient { - send_compression_encoding: Some(CompressionEncoding::Gzip), + send_compression_encoding, mk: builder.build(), } } @@ -184,6 +185,7 @@ impl TripleClient { .header("path", path.to_string()) .body(body) .unwrap(); + self.set_compression_headers(request.headers_mut()); for (k, v) in mt.into_headers().iter() { request.headers_mut().insert(k, v.to_owned()); @@ -252,6 +254,7 @@ impl TripleClient { .header("path", path.to_string()) .body(body) .unwrap(); + self.set_compression_headers(request.headers_mut()); for (k, v) in mt.into_headers().iter() { request.headers_mut().insert(k, v.to_owned()); @@ -323,6 +326,7 @@ impl TripleClient { .header("path", path.to_string()) .body(body) .unwrap(); + self.set_compression_headers(request.headers_mut()); for (k, v) in mt.into_headers().iter() { request.headers_mut().insert(k, v.to_owned()); @@ -377,6 +381,7 @@ impl TripleClient { .header("path", path.to_string()) .body(body) .unwrap(); + self.set_compression_headers(request.headers_mut()); for (k, v) in mt.into_headers().iter() { request.headers_mut().insert(k, v.to_owned()); @@ -448,6 +453,7 @@ impl TripleClient { .header("path", path.to_string()) .body(body) .unwrap(); + self.set_compression_headers(request.headers_mut()); for (k, v) in mt.into_headers().iter() { request.headers_mut().insert(k, v.to_owned()); @@ -468,6 +474,13 @@ impl TripleClient { Err(err) => Err(err), } } + + fn set_compression_headers(&self, headers: &mut http::HeaderMap) { + if let Some(encoding) = self.send_compression_encoding { + headers.insert("grpc-encoding", encoding.into_header_value()); + headers.insert("grpc-accept-encoding", encoding.into_header_value()); + } + } } fn insert_default_header(headers: &mut http::HeaderMap, name: &'static str, value: HeaderValue) { From 3cad65c076ff2e73a7bc0f1a3eb2fc2504039dff Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 10:05:46 +0800 Subject: [PATCH 19/23] feat: honor provider weight in random load balancer --- dubbo-rs-core/README.md | 3 +- dubbo-rs-core/tests/raw_unary.rs | 59 +++++++++++++++++++++++ dubbo/src/loadbalancer/mod.rs | 9 ++++ dubbo/src/loadbalancer/random.rs | 82 ++++++++++++++++++++++++++++++-- 4 files changed, 149 insertions(+), 4 deletions(-) diff --git a/dubbo-rs-core/README.md b/dubbo-rs-core/README.md index c846a8ca..4da4b0c7 100644 --- a/dubbo-rs-core/README.md +++ b/dubbo-rs-core/README.md @@ -18,7 +18,8 @@ keep the user-facing client API while a N-API addon reuses the Rust core. - Static provider endpoints. - Registry-backed clients behind optional features. - Request-level and client-default unary timeouts. -- Load balancing: `random`, `round_robin`, and `p2c`. +- Load balancing: `random`, `round_robin`, and `p2c`; `random` honors provider + `weight` URL parameters. - Cluster strategy: `failfast` and `failover`, with configurable failover retries. - Compression: `gzip` or `identity`. - Metadata routing for tag, group, and version. diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index 87a367c1..2a9ead84 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -349,6 +349,65 @@ async fn raw_unary_routes_static_endpoints_by_tag() { green_server_task.await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_random_load_balance_honors_provider_weight() { + const SERVICE: &str = "grpc.examples.echo.WeightedEndpointEcho"; + let zero_addr = unused_local_addr(); + let weighted_addr = unused_local_addr(); + let (zero_shutdown_tx, zero_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let (weighted_shutdown_tx, weighted_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let zero_server_task = spawn_server_with_payload( + zero_addr, + zero_shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(0), + Bytes::from_static(b"\x0a\x04zero"), + ); + let weighted_server_task = spawn_server_with_payload( + weighted_addr, + weighted_shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(0), + Bytes::from_static(b"\x0a\x08weighted"), + ); + + wait_for_server(zero_addr).await; + wait_for_server(weighted_addr).await; + + let zero_endpoint = format!("http://{zero_addr}?interface={SERVICE}&weight=0"); + let weighted_endpoint = format!("http://{weighted_addr}?interface={SERVICE}&weight=100"); + let mut client = RawTripleClient::from_static_endpoints_with_options( + [zero_endpoint.as_str(), weighted_endpoint.as_str()], + RawTripleClientOptions { + load_balance: Some("random".to_string()), + ..RawTripleClientOptions::default() + }, + ) + .unwrap(); + + for _ in 0..5 { + let response = client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new(), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(10_000), + }) + .await + .unwrap(); + + assert_eq!(response.body, Bytes::from_static(b"\x0a\x08weighted")); + } + + let _ = zero_shutdown_tx.send(()); + let _ = weighted_shutdown_tx.send(()); + zero_server_task.await.unwrap(); + weighted_server_task.await.unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_routes_static_endpoints_by_group_and_version() { const SERVICE: &str = "grpc.examples.echo.GroupVersionEndpointEcho"; diff --git a/dubbo/src/loadbalancer/mod.rs b/dubbo/src/loadbalancer/mod.rs index 0855d9b9..6360ca98 100644 --- a/dubbo/src/loadbalancer/mod.rs +++ b/dubbo/src/loadbalancer/mod.rs @@ -37,6 +37,7 @@ use crate::{ loadbalancer::random::RandomLoadBalancer, param::Param, protocol::triple::triple_invoker::TripleInvoker, + status::{Code, Status}, svc::NewService, StdError, }; @@ -128,6 +129,14 @@ where // invks.oneshot(req).await // let service_list = ServiceList::new(service_list); + if routes.is_empty() { + return Err(Status::new( + Code::Unavailable, + "no provider available for request".to_string(), + ) + .into()); + } + // let p2c = tower::balance::p2c::Balance::new(service_list); // let p: Box, http::Response>, Box>> + std::marker::Send + std::marker::Sync> = get_loadbalancer("p2c").into(); let p = get_loadbalancer(&strategy, round_robin_next); diff --git a/dubbo/src/loadbalancer/random.rs b/dubbo/src/loadbalancer/random.rs index 7739cba4..1cd4d568 100644 --- a/dubbo/src/loadbalancer/random.rs +++ b/dubbo/src/loadbalancer/random.rs @@ -15,15 +15,21 @@ * limitations under the License. */ -use rand::prelude::SliceRandom; +use rand::{ + distributions::{Distribution, WeightedIndex}, + Rng, +}; use tracing::debug; use super::{DubboBoxService, LoadBalancer}; use crate::{ invocation::Metadata, loadbalancer::CloneInvoker, - protocol::triple::triple_invoker::TripleInvoker, + protocol::triple::triple_invoker::TripleInvoker, Url, }; +const DEFAULT_PROVIDER_WEIGHT: u32 = 100; +const PROVIDER_WEIGHT_KEY: &str = "weight"; + #[derive(Clone, Default)] pub struct RandomLoadBalancer {} @@ -36,7 +42,77 @@ impl LoadBalancer for RandomLoadBalancer { metadata: Metadata, ) -> Self::Invoker { debug!("random loadbalance {:?}", metadata); - let ivk = invokers.choose(&mut rand::thread_rng()).unwrap().clone(); + let mut rng = rand::thread_rng(); + let index = weighted_random_index(&invokers, &mut rng) + .unwrap_or_else(|| rng.gen_range(0..invokers.len())); + let ivk = invokers[index].clone(); DubboBoxService::new(ivk) } } + +fn weighted_random_index( + invokers: &[CloneInvoker], + rng: &mut R, +) -> Option { + let weights = invokers + .iter() + .map(|invoker| provider_weight(invoker.url())) + .collect::>(); + + weighted_index(&weights, rng) +} + +fn weighted_index(weights: &[u32], rng: &mut R) -> Option { + WeightedIndex::new(weights) + .ok() + .map(|dist| dist.sample(rng)) +} + +fn provider_weight(url: Option<&Url>) -> u32 { + url.and_then(|url| url.query_param_by_key(PROVIDER_WEIGHT_KEY)) + .and_then(|weight| weight.parse::().ok()) + .unwrap_or(DEFAULT_PROVIDER_WEIGHT) +} + +#[cfg(test)] +mod tests { + use rand::{rngs::StdRng, SeedableRng}; + + use super::*; + + #[test] + fn provider_weight_defaults_invalid_or_missing_weight() { + let no_weight = "http://127.0.0.1:50051?interface=example.Echo" + .parse::() + .unwrap(); + let invalid_weight = "http://127.0.0.1:50051?interface=example.Echo&weight=bad" + .parse::() + .unwrap(); + let explicit_weight = "http://127.0.0.1:50051?interface=example.Echo&weight=25" + .parse::() + .unwrap(); + + assert_eq!(provider_weight(Some(&no_weight)), DEFAULT_PROVIDER_WEIGHT); + assert_eq!( + provider_weight(Some(&invalid_weight)), + DEFAULT_PROVIDER_WEIGHT + ); + assert_eq!(provider_weight(Some(&explicit_weight)), 25); + } + + #[test] + fn weighted_index_ignores_zero_weight_when_positive_weight_exists() { + let mut rng = StdRng::seed_from_u64(7); + + for _ in 0..100 { + assert_eq!(weighted_index(&[0, 100], &mut rng), Some(1)); + } + } + + #[test] + fn weighted_index_returns_none_when_all_weights_are_zero() { + let mut rng = StdRng::seed_from_u64(7); + + assert_eq!(weighted_index(&[0, 0], &mut rng), None); + } +} From ccbf219e623fae029106ec0965c7dfeec75f4a51 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 10:08:28 +0800 Subject: [PATCH 20/23] feat: honor provider weight in round robin load balancer --- dubbo-rs-core/README.md | 4 +- dubbo-rs-core/tests/raw_unary.rs | 59 ++++++++++++++++++++ dubbo/src/loadbalancer/mod.rs | 95 +++++++++++++++++++++++++++++++- dubbo/src/loadbalancer/random.rs | 33 +---------- 4 files changed, 156 insertions(+), 35 deletions(-) diff --git a/dubbo-rs-core/README.md b/dubbo-rs-core/README.md index 4da4b0c7..be2c9573 100644 --- a/dubbo-rs-core/README.md +++ b/dubbo-rs-core/README.md @@ -18,8 +18,8 @@ keep the user-facing client API while a N-API addon reuses the Rust core. - Static provider endpoints. - Registry-backed clients behind optional features. - Request-level and client-default unary timeouts. -- Load balancing: `random`, `round_robin`, and `p2c`; `random` honors provider - `weight` URL parameters. +- Load balancing: `random`, `round_robin`, and `p2c`; `random` and + `round_robin` honor provider `weight` URL parameters. - Cluster strategy: `failfast` and `failover`, with configurable failover retries. - Compression: `gzip` or `identity`. - Metadata routing for tag, group, and version. diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index 2a9ead84..ff9205e5 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -408,6 +408,65 @@ async fn raw_unary_random_load_balance_honors_provider_weight() { weighted_server_task.await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_round_robin_load_balance_honors_provider_weight() { + const SERVICE: &str = "grpc.examples.echo.WeightedRoundRobinEndpointEcho"; + let zero_addr = unused_local_addr(); + let weighted_addr = unused_local_addr(); + let (zero_shutdown_tx, zero_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let (weighted_shutdown_tx, weighted_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let zero_server_task = spawn_server_with_payload( + zero_addr, + zero_shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(0), + Bytes::from_static(b"\x0a\x04zero"), + ); + let weighted_server_task = spawn_server_with_payload( + weighted_addr, + weighted_shutdown_rx, + SERVICE.to_string(), + Duration::from_millis(0), + Bytes::from_static(b"\x0a\x08weighted"), + ); + + wait_for_server(zero_addr).await; + wait_for_server(weighted_addr).await; + + let zero_endpoint = format!("http://{zero_addr}?interface={SERVICE}&weight=0"); + let weighted_endpoint = format!("http://{weighted_addr}?interface={SERVICE}&weight=100"); + let mut client = RawTripleClient::from_static_endpoints_with_options( + [zero_endpoint.as_str(), weighted_endpoint.as_str()], + RawTripleClientOptions { + load_balance: Some("round_robin".to_string()), + ..RawTripleClientOptions::default() + }, + ) + .unwrap(); + + for _ in 0..5 { + let response = client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new(), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(10_000), + }) + .await + .unwrap(); + + assert_eq!(response.body, Bytes::from_static(b"\x0a\x08weighted")); + } + + let _ = zero_shutdown_tx.send(()); + let _ = weighted_shutdown_tx.send(()); + zero_server_task.await.unwrap(); + weighted_server_task.await.unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_routes_static_endpoints_by_group_and_version() { const SERVICE: &str = "grpc.examples.echo.GroupVersionEndpointEcho"; diff --git a/dubbo/src/loadbalancer/mod.rs b/dubbo/src/loadbalancer/mod.rs index 6360ca98..78d3a7b6 100644 --- a/dubbo/src/loadbalancer/mod.rs +++ b/dubbo/src/loadbalancer/mod.rs @@ -39,9 +39,12 @@ use crate::{ protocol::triple::triple_invoker::TripleInvoker, status::{Code, Status}, svc::NewService, - StdError, + StdError, Url, }; +const DEFAULT_PROVIDER_WEIGHT: u32 = 100; +const PROVIDER_WEIGHT_KEY: &str = "weight"; + pub struct NewLoadBalancer { inner: N, strategy: LoadBalanceStrategy, @@ -248,7 +251,95 @@ impl LoadBalancer for RoundRobinLoadBalancer { _metadata: Metadata, ) -> Self::Invoker { debug!("round-robin load balancer"); - let index = self.next.fetch_add(1, Ordering::Relaxed) % invokers.len(); + let next = self.next.fetch_add(1, Ordering::Relaxed); + let index = + weighted_round_robin_index(&invokers, next).unwrap_or_else(|| next % invokers.len()); DubboBoxService::new(invokers[index].clone()) } } + +fn weighted_round_robin_index( + invokers: &[CloneInvoker], + next: usize, +) -> Option { + let weights = invokers + .iter() + .map(|invoker| provider_weight(invoker.url())) + .collect::>(); + + weighted_round_robin_index_for_weights(&weights, next) +} + +fn weighted_round_robin_index_for_weights(weights: &[u32], next: usize) -> Option { + let total_weight = weights.iter().try_fold(0usize, |total, weight| { + total.checked_add(usize::try_from(*weight).ok()?) + })?; + if total_weight == 0 { + return None; + } + + let selected_weight = next % total_weight; + let mut cumulative_weight = 0usize; + for (index, weight) in weights.iter().enumerate() { + cumulative_weight += usize::try_from(*weight).ok()?; + if selected_weight < cumulative_weight { + return Some(index); + } + } + + None +} + +fn provider_weight(url: Option<&Url>) -> u32 { + url.and_then(|url| url.query_param_by_key(PROVIDER_WEIGHT_KEY)) + .and_then(|weight| weight.parse::().ok()) + .unwrap_or(DEFAULT_PROVIDER_WEIGHT) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_weight_defaults_invalid_or_missing_weight() { + let no_weight = "http://127.0.0.1:50051?interface=example.Echo" + .parse::() + .unwrap(); + let invalid_weight = "http://127.0.0.1:50051?interface=example.Echo&weight=bad" + .parse::() + .unwrap(); + let explicit_weight = "http://127.0.0.1:50051?interface=example.Echo&weight=25" + .parse::() + .unwrap(); + + assert_eq!(provider_weight(Some(&no_weight)), DEFAULT_PROVIDER_WEIGHT); + assert_eq!( + provider_weight(Some(&invalid_weight)), + DEFAULT_PROVIDER_WEIGHT + ); + assert_eq!(provider_weight(Some(&explicit_weight)), 25); + } + + #[test] + fn weighted_round_robin_index_skips_zero_weight_when_positive_weight_exists() { + for next in 0..10 { + assert_eq!( + weighted_round_robin_index_for_weights(&[0, 100], next), + Some(1) + ); + } + } + + #[test] + fn weighted_round_robin_index_uses_weighted_slots() { + assert_eq!(weighted_round_robin_index_for_weights(&[1, 2], 0), Some(0)); + assert_eq!(weighted_round_robin_index_for_weights(&[1, 2], 1), Some(1)); + assert_eq!(weighted_round_robin_index_for_weights(&[1, 2], 2), Some(1)); + assert_eq!(weighted_round_robin_index_for_weights(&[1, 2], 3), Some(0)); + } + + #[test] + fn weighted_round_robin_index_returns_none_when_all_weights_are_zero() { + assert_eq!(weighted_round_robin_index_for_weights(&[0, 0], 0), None); + } +} diff --git a/dubbo/src/loadbalancer/random.rs b/dubbo/src/loadbalancer/random.rs index 1cd4d568..af9cf817 100644 --- a/dubbo/src/loadbalancer/random.rs +++ b/dubbo/src/loadbalancer/random.rs @@ -21,15 +21,12 @@ use rand::{ }; use tracing::debug; -use super::{DubboBoxService, LoadBalancer}; +use super::{provider_weight, DubboBoxService, LoadBalancer}; use crate::{ invocation::Metadata, loadbalancer::CloneInvoker, - protocol::triple::triple_invoker::TripleInvoker, Url, + protocol::triple::triple_invoker::TripleInvoker, }; -const DEFAULT_PROVIDER_WEIGHT: u32 = 100; -const PROVIDER_WEIGHT_KEY: &str = "weight"; - #[derive(Clone, Default)] pub struct RandomLoadBalancer {} @@ -68,38 +65,12 @@ fn weighted_index(weights: &[u32], rng: &mut R) -> Option) -> u32 { - url.and_then(|url| url.query_param_by_key(PROVIDER_WEIGHT_KEY)) - .and_then(|weight| weight.parse::().ok()) - .unwrap_or(DEFAULT_PROVIDER_WEIGHT) -} - #[cfg(test)] mod tests { use rand::{rngs::StdRng, SeedableRng}; use super::*; - #[test] - fn provider_weight_defaults_invalid_or_missing_weight() { - let no_weight = "http://127.0.0.1:50051?interface=example.Echo" - .parse::() - .unwrap(); - let invalid_weight = "http://127.0.0.1:50051?interface=example.Echo&weight=bad" - .parse::() - .unwrap(); - let explicit_weight = "http://127.0.0.1:50051?interface=example.Echo&weight=25" - .parse::() - .unwrap(); - - assert_eq!(provider_weight(Some(&no_weight)), DEFAULT_PROVIDER_WEIGHT); - assert_eq!( - provider_weight(Some(&invalid_weight)), - DEFAULT_PROVIDER_WEIGHT - ); - assert_eq!(provider_weight(Some(&explicit_weight)), 25); - } - #[test] fn weighted_index_ignores_zero_weight_when_positive_weight_exists() { let mut rng = StdRng::seed_from_u64(7); From 9181e6c546fb689c746fd085eb4fca8630977560 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 10:13:33 +0800 Subject: [PATCH 21/23] feat: configure failover retry delay --- dubbo-rs-core/README.md | 4 +- dubbo-rs-core/src/lib.rs | 6 +++ dubbo-rs-core/tests/raw_unary.rs | 4 +- dubbo/src/cluster/mod.rs | 69 +++++++++++++++++++++++++++--- dubbo/src/triple/client/builder.rs | 9 +++- 5 files changed, 81 insertions(+), 11 deletions(-) diff --git a/dubbo-rs-core/README.md b/dubbo-rs-core/README.md index be2c9573..2b169872 100644 --- a/dubbo-rs-core/README.md +++ b/dubbo-rs-core/README.md @@ -20,7 +20,8 @@ keep the user-facing client API while a N-API addon reuses the Rust core. - Request-level and client-default unary timeouts. - Load balancing: `random`, `round_robin`, and `p2c`; `random` and `round_robin` honor provider `weight` URL parameters. -- Cluster strategy: `failfast` and `failover`, with configurable failover retries. +- Cluster strategy: `failfast` and `failover`, with configurable failover + retries and retry delay. - Compression: `gzip` or `identity`. - Metadata routing for tag, group, and version. - Stable status code and message access for host-language error mapping. @@ -47,6 +48,7 @@ let mut client = RawTripleClient::from_static_endpoints_with_options( load_balance: Some("round_robin".to_string()), cluster: Some("failfast".to_string()), failover_retries: None, + failover_retry_delay_ms: None, compression: Some("gzip".to_string()), ..RawTripleClientOptions::default() }, diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index dd35d3c5..648691bf 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -162,6 +162,7 @@ pub struct RawTripleClientOptions { pub load_balance: Option, pub cluster: Option, pub failover_retries: Option, + pub failover_retry_delay_ms: Option, pub compression: Option, pub default_metadata: RawMetadata, } @@ -212,6 +213,11 @@ impl RawTripleClientOptions { None => builder, }; + let builder = match self.failover_retry_delay_ms { + Some(delay_ms) => builder.with_failover_retry_delay(Duration::from_millis(delay_ms)), + None => builder, + }; + let builder = match self.compression.as_deref() { Some("gzip") => builder.with_compression(Some(CompressionEncoding::Gzip)), Some("identity") | Some("none") => builder.with_compression(None), diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index ff9205e5..0c6c90d8 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -567,7 +567,7 @@ async fn raw_unary_timeout_returns_deadline_exceeded() { addr, shutdown_rx, SERVICE.to_string(), - Duration::from_millis(100), + Duration::from_millis(500), ); wait_for_server(addr).await; @@ -581,7 +581,7 @@ async fn raw_unary_timeout_returns_deadline_exceeded() { path: format!("/{SERVICE}/UnaryEcho"), metadata: RawMetadata::new(), body: Bytes::from_static(b"\x0a\x08dubbo-js"), - timeout_ms: Some(10), + timeout_ms: Some(50), }) .await .unwrap_err(); diff --git a/dubbo/src/cluster/mod.rs b/dubbo/src/cluster/mod.rs index 15c3ac53..27d53dc9 100644 --- a/dubbo/src/cluster/mod.rs +++ b/dubbo/src/cluster/mod.rs @@ -17,6 +17,7 @@ use futures_core::future::BoxFuture; use http::Request; +use std::time::Duration; use tower::ServiceExt; use tower_service::Service; @@ -64,13 +65,17 @@ where #[derive(Clone, Debug, PartialEq, Eq)] pub enum ClusterStrategy { Failfast, - Failover { attempts: usize }, + Failover { + attempts: usize, + retry_delay: Duration, + }, } impl Default for ClusterStrategy { fn default() -> Self { Self::Failover { attempts: Self::DEFAULT_FAILOVER_ATTEMPTS, + retry_delay: Duration::from_millis(0), } } } @@ -88,8 +93,19 @@ impl ClusterStrategy { pub fn with_failover_attempts(self, attempts: usize) -> Self { match self { - Self::Failover { .. } => Self::Failover { + Self::Failover { retry_delay, .. } => Self::Failover { attempts: attempts.max(1), + retry_delay, + }, + Self::Failfast => Self::Failfast, + } + } + + pub fn with_failover_retry_delay(self, retry_delay: Duration) -> Self { + match self { + Self::Failover { attempts, .. } => Self::Failover { + attempts, + retry_delay, }, Self::Failfast => Self::Failfast, } @@ -147,16 +163,24 @@ where .oneshot(make_request(clone_body, Some(extensions))) .await } - ClusterStrategy::Failover { attempts } => { + ClusterStrategy::Failover { + attempts, + retry_delay, + } => { let mut last_error = None; let mut first_body = Some(clone_body); let mut first_extensions = Some(extensions); - for _ in 0..attempts { + for attempt in 0..attempts { let body = first_body.take().unwrap_or_else(|| replay_body.clone()); let extensions = first_extensions.take(); match inner.clone().oneshot(make_request(body, extensions)).await { Ok(response) => return Ok(response), - Err(err) => last_error = Some(err), + Err(err) => { + last_error = Some(err); + if attempt + 1 < attempts && !retry_delay.is_zero() { + tokio::time::sleep(retry_delay).await; + } + } } } @@ -175,6 +199,7 @@ mod tests { Arc, }, task::{Context, Poll}, + time::Duration, }; use crate::invoker::clone_body::CloneBody; @@ -219,7 +244,10 @@ mod tests { calls: Arc::clone(&calls), failures: 1, }, - strategy: ClusterStrategy::Failover { attempts: 2 }, + strategy: ClusterStrategy::Failover { + attempts: 2, + retry_delay: Duration::from_millis(0), + }, }; cluster @@ -238,7 +266,10 @@ mod tests { calls: Arc::clone(&calls), failures: 2, }, - strategy: ClusterStrategy::Failover { attempts: 1 }, + strategy: ClusterStrategy::Failover { + attempts: 1, + retry_delay: Duration::from_millis(0), + }, }; cluster @@ -248,4 +279,28 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); } + + #[tokio::test] + async fn failover_waits_between_attempts() { + let calls = Arc::new(AtomicUsize::new(0)); + let mut cluster = Cluster { + inner: FailsBeforeSuccess { + calls: Arc::clone(&calls), + failures: 1, + }, + strategy: ClusterStrategy::Failover { + attempts: 2, + retry_delay: Duration::from_millis(20), + }, + }; + + let started = std::time::Instant::now(); + cluster + .call(Request::new(hyper::Body::empty())) + .await + .unwrap(); + + assert!(started.elapsed() >= Duration::from_millis(20)); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } } diff --git a/dubbo/src/triple/client/builder.rs b/dubbo/src/triple/client/builder.rs index ab98ffc6..810b48ac 100644 --- a/dubbo/src/triple/client/builder.rs +++ b/dubbo/src/triple/client/builder.rs @@ -15,7 +15,7 @@ * limitations under the License. */ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use crate::{ cluster::{ClusterStrategy, NewCluster}, @@ -143,6 +143,13 @@ impl ClientBuilder { } } + pub fn with_failover_retry_delay(self, retry_delay: Duration) -> Self { + Self { + cluster: self.cluster.with_failover_retry_delay(retry_delay), + ..self + } + } + pub fn with_compression(self, compression: Option) -> Self { Self { send_compression_encoding: compression, From 457c61d4d455da0149284249b5f57c3a1dbd9175 Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 16 Jul 2026 10:19:01 +0800 Subject: [PATCH 22/23] docs: document core crate publishing path --- dubbo-rs-core/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/dubbo-rs-core/README.md b/dubbo-rs-core/README.md index 2b169872..4ba8aa4f 100644 --- a/dubbo-rs-core/README.md +++ b/dubbo-rs-core/README.md @@ -32,6 +32,21 @@ keep the user-facing client API while a N-API addon reuses the Rust core. - `registry-zookeeper`: enable Zookeeper registry support. - `registry`: enable all registry backends currently wired into this crate. +## Publishing And Host Usage + +This crate is intended to be consumed by host-language bindings, such as a +Node.js N-API package. During local development those bindings can depend on the +crate by path. After publishing, switch them to a normal version dependency and +enable only the registry features they need: + +```toml +dubbo-rs-core = { version = "0.1", features = ["registry"] } +``` + +The crate keeps protobuf encoding and generated service shape outside the Rust +boundary. Host runtimes pass raw request bytes, metadata, and endpoint or +registry options into Rust, then decode raw response bytes themselves. + ## Example ```rust From 428d83921d975fd120eaced969cccf767c34aa8b Mon Sep 17 00:00:00 2001 From: Carbon Date: Fri, 17 Jul 2026 01:40:20 +0800 Subject: [PATCH 23/23] Add raw unary trailer support to core --- dubbo-rs-core/src/lib.rs | 12 +++- dubbo-rs-core/tests/raw_unary.rs | 107 +++++++++++++++++++++++++++--- dubbo/src/triple/client/triple.rs | 30 ++++++--- 3 files changed, 130 insertions(+), 19 deletions(-) diff --git a/dubbo-rs-core/src/lib.rs b/dubbo-rs-core/src/lib.rs index 648691bf..9f989931 100644 --- a/dubbo-rs-core/src/lib.rs +++ b/dubbo-rs-core/src/lib.rs @@ -127,7 +127,7 @@ impl RawTripleClient { .with_service_unique_name(request.service) .with_method_name(request.method); let timeout_ms = request.timeout_ms.or(self.default_timeout_ms); - let call = self.inner.raw_unary( + let call = self.inner.raw_unary_with_trailers( Request::from_parts( self.default_metadata.clone().merge(request.metadata).into(), request.body, @@ -147,10 +147,12 @@ impl RawTripleClient { } else { call.await? }; + let (response, trailers) = response; let (metadata, body) = response.into_parts(); Ok(RawUnaryResponse { metadata: metadata.into(), + trailers: trailers.map(Into::into).unwrap_or_default(), body, }) } @@ -275,6 +277,7 @@ pub struct RawUnaryRequest { #[derive(Debug, Clone)] pub struct RawUnaryResponse { pub metadata: RawMetadata, + pub trailers: RawMetadata, pub body: Bytes, } @@ -297,6 +300,13 @@ impl RawMetadata { self.entries.extend(other.entries); self } + + pub fn get(&self, key: &str) -> Option<&String> { + self.entries + .iter() + .rev() + .find_map(|(entry_key, value)| (entry_key == key).then_some(value)) + } } impl From for Metadata { diff --git a/dubbo-rs-core/tests/raw_unary.rs b/dubbo-rs-core/tests/raw_unary.rs index 0c6c90d8..17a91da8 100644 --- a/dubbo-rs-core/tests/raw_unary.rs +++ b/dubbo-rs-core/tests/raw_unary.rs @@ -42,6 +42,9 @@ struct MetadataEchoService; #[derive(Clone)] struct CompressionEchoService; +#[derive(Clone)] +struct HeaderTrailerEchoService; + impl Service> for SlowRawUnaryService { type Response = http::Response; type Error = Infallible; @@ -146,10 +149,55 @@ impl Service> for CompressionEchoService { } } +impl Service> for HeaderTrailerEchoService { + type Response = http::Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: http::Request) -> Self::Future { + Box::pin(async move { + let (mut sender, body) = hyper::Body::channel(); + tokio::spawn(async move { + let _ = sender + .send_data(grpc_frame(Bytes::from_static(b"\x0a\x0draw response"))) + .await; + let mut trailers = http::HeaderMap::new(); + trailers.insert("x-trailer", http::HeaderValue::from_static("done")); + let _ = sender.send_trailers(trailers).await; + }); + + let body = http_body::combinators::UnsyncBoxBody::new(body.map_err(|err| { + dubbo::status::Status::new(dubbo::status::Code::Internal, err.to_string()) + })); + + Ok(http::Response::builder() + .status(http::StatusCode::OK) + .header("content-type", "application/grpc+proto") + .header("x-response-header", "ready") + .body(body) + .unwrap()) + }) + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn raw_unary_uses_static_endpoint_list_and_maps_timeout() { +async fn raw_unary_core_integration_suite() { raw_unary_uses_static_endpoint_list().await; raw_unary_timeout_returns_deadline_exceeded().await; + raw_unary_applies_default_metadata_and_allows_request_override().await; + raw_unary_separates_response_headers_and_trailers().await; + raw_unary_configures_compression().await; + raw_unary_routes_static_endpoints_by_tag().await; + raw_unary_random_load_balance_honors_provider_weight().await; + raw_unary_round_robin_load_balance_honors_provider_weight().await; + raw_unary_routes_static_endpoints_by_group_and_version().await; + raw_unary_errors_when_routing_metadata_matches_no_provider().await; + raw_unary_uses_client_default_timeout().await; + raw_unary_request_timeout_overrides_client_default_timeout().await; } async fn raw_unary_uses_static_endpoint_list() { @@ -189,7 +237,54 @@ async fn raw_unary_uses_static_endpoint_list() { server_task.await.unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn raw_unary_separates_response_headers_and_trailers() { + const SERVICE: &str = "grpc.examples.echo.HeaderTrailerEcho"; + let addr = unused_local_addr(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let server_task = tokio::spawn(async move { + DubboServer::new() + .with_listener("tcp".to_string()) + .add_service(SERVICE.to_string(), HeaderTrailerEchoService) + .serve_with_graceful(addr, async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + wait_for_server(addr).await; + + let endpoint = format!("http://{addr}?interface={SERVICE}"); + let mut client = RawTripleClient::from_static(&endpoint); + let response = client + .unary(RawUnaryRequest { + service: SERVICE.to_string(), + method: "UnaryEcho".to_string(), + path: format!("/{SERVICE}/UnaryEcho"), + metadata: RawMetadata::new(), + body: Bytes::from_static(b"\x0a\x08dubbo-js"), + timeout_ms: Some(10_000), + }) + .await + .unwrap(); + + assert_eq!( + response + .metadata + .get("x-response-header") + .map(String::as_str), + Some("ready") + ); + assert_eq!( + response.trailers.get("x-trailer").map(String::as_str), + Some("done") + ); + + let _ = shutdown_tx.send(()); + server_task.await.unwrap(); +} + async fn raw_unary_applies_default_metadata_and_allows_request_override() { const SERVICE: &str = "grpc.examples.echo.DefaultMetadataEcho"; let addr = unused_local_addr(); @@ -237,7 +332,6 @@ async fn raw_unary_applies_default_metadata_and_allows_request_override() { server_task.await.unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_configures_compression() { const SERVICE: &str = "grpc.examples.echo.CompressionEcho"; let addr = unused_local_addr(); @@ -298,7 +392,6 @@ async fn raw_unary_configures_compression() { server_task.await.unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_routes_static_endpoints_by_tag() { const SERVICE: &str = "grpc.examples.echo.TaggedEndpointEcho"; let blue_addr = unused_local_addr(); @@ -349,7 +442,6 @@ async fn raw_unary_routes_static_endpoints_by_tag() { green_server_task.await.unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_random_load_balance_honors_provider_weight() { const SERVICE: &str = "grpc.examples.echo.WeightedEndpointEcho"; let zero_addr = unused_local_addr(); @@ -408,7 +500,6 @@ async fn raw_unary_random_load_balance_honors_provider_weight() { weighted_server_task.await.unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_round_robin_load_balance_honors_provider_weight() { const SERVICE: &str = "grpc.examples.echo.WeightedRoundRobinEndpointEcho"; let zero_addr = unused_local_addr(); @@ -467,7 +558,6 @@ async fn raw_unary_round_robin_load_balance_honors_provider_weight() { weighted_server_task.await.unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_routes_static_endpoints_by_group_and_version() { const SERVICE: &str = "grpc.examples.echo.GroupVersionEndpointEcho"; let blue_addr = unused_local_addr(); @@ -521,7 +611,6 @@ async fn raw_unary_routes_static_endpoints_by_group_and_version() { green_server_task.await.unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_errors_when_routing_metadata_matches_no_provider() { const SERVICE: &str = "grpc.examples.echo.NoMatchedEndpointEcho"; let addr = unused_local_addr(); @@ -592,7 +681,6 @@ async fn raw_unary_timeout_returns_deadline_exceeded() { server_task.await.unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_uses_client_default_timeout() { const SERVICE: &str = "grpc.examples.echo.DefaultTimeoutEcho"; let addr = unused_local_addr(); @@ -634,7 +722,6 @@ async fn raw_unary_uses_client_default_timeout() { server_task.await.unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn raw_unary_request_timeout_overrides_client_default_timeout() { const SERVICE: &str = "grpc.examples.echo.OverrideTimeoutEcho"; let addr = unused_local_addr(); diff --git a/dubbo/src/triple/client/triple.rs b/dubbo/src/triple/client/triple.rs index 5c4ade5f..399ff2a8 100644 --- a/dubbo/src/triple/client/triple.rs +++ b/dubbo/src/triple/client/triple.rs @@ -227,8 +227,26 @@ impl TripleClient { &mut self, req: Request, path: http::uri::PathAndQuery, - mut invocation: RpcInvocation, + invocation: RpcInvocation, ) -> Result, crate::status::Status> { + let (response, trailers) = self.raw_unary_with_trailers(req, path, invocation).await?; + let (mut parts, message) = response.into_parts(); + + if let Some(trailers) = trailers { + let mut h = parts.into_headers(); + h.extend(trailers.into_headers()); + parts = Metadata::from_headers(h); + } + + Ok(Response::from_parts(parts, message)) + } + + pub async fn raw_unary_with_trailers( + &mut self, + req: Request, + path: http::uri::PathAndQuery, + mut invocation: RpcInvocation, + ) -> Result<(Response, Option), crate::status::Status> { let mut codec = BytesCodec::default(); let decoder: Box + Send + 'static> = Box::new(codec.decoder()); @@ -269,7 +287,7 @@ impl TripleClient { Ok(v) => { let resp = v .map(|body| Decoding::new(body, decoder, self.send_compression_encoding, true)); - let (mut parts, body) = Response::from_http(resp).into_parts(); + let (parts, body) = Response::from_http(resp).into_parts(); futures_util::pin_mut!(body); @@ -280,13 +298,9 @@ impl TripleClient { ) })?; - if let Some(trailers) = body.trailer().await? { - let mut h = parts.into_headers(); - h.extend(trailers.into_headers()); - parts = Metadata::from_headers(h); - } + let trailers = body.trailer().await?; - Ok(Response::from_parts(parts, message)) + Ok((Response::from_parts(parts, message), trailers)) } Err(err) => Err(err), }