Skip to content

PoC: gRPC server standardization#1742

Open
Mirko-von-Leipzig wants to merge 16 commits intomirko/build-codegenfrom
mirko/grpc-servers
Open

PoC: gRPC server standardization#1742
Mirko-von-Leipzig wants to merge 16 commits intomirko/build-codegenfrom
mirko/grpc-servers

Conversation

@Mirko-von-Leipzig
Copy link
Collaborator

@Mirko-von-Leipzig Mirko-von-Leipzig commented Mar 3, 2026

Proof of concept for gRPC server implementations.

Each gRPC method gets its own trait (generated by build.rs):

trait <Method> {
    type Input;
    type Output;

    fn decode(request: proto::RequestType) -> tonic::Result<Self::Input>;

    fn encode(output: Self::Output) -> tonic::Result<proto::ResponseType>;

    async fn handle(&self, input: Self::Input) -> tonic::Result<Self::Output>;

    // Implementers can override the behaviour e.g. if its just a proxy shim, then we
    // can just do proxy_client.request(request).await
    async fn full(
        &self,
        request: proto::RequestType,
    ) -> tonic::Result<proto::ResponseType> {
        let input = Self::decode(request)?;
        let output = self.handle(input).await?;
        Self::encode(output)
    }
}

The implementation is not fully complete; I don't support the mempool subscription stream here for example. The comments in the build.rs are also outdated and reference an initial implementation I had.

Some benefits of doing this, over implementing the tonic generated traits:

  • We can easily add instrumentation to encode and decode and handle in one location.
  • This lays the foundation for GrpcDecode and GrpcEncode traits as a follow-on, which I think will let us be more consistent with our errors here.
  • This will also let us do something like Server::public_facing and Server::internal, which changes how errors are encoded (i.e. hide internal errors for public facing).
  • Because each method is its own trait, we can implement one method per file.
  • Standard implementation of each method.

I've implemented the validator api as an example.

Looking for feedback if this is worth pursuing and cleaning up :) (imo yes, but maybe its not worth it).

return Ok(());
}

let status = Command::new("rustfmt")
Copy link
Contributor

@drahnr drahnr Mar 3, 2026

Choose a reason for hiding this comment

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

we should try to avoid calling external funbi aries, particularly when it's impossible to pass on flags (i.e. which version / edition does it expect?)

I suggest to use something like prettyplease for generated code https://github.com/drahnr/expander/blob/master/src/lib.rs#L239-L246

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I used prettyplease originally, and it gave notably worse output - and also worse dx because if you enter the generated file manually and hit save, your ide may autofmt it again.

I figured using rustfmt would be much better? I don't really care what version or edition it uses - its just to make it legible. It is actually possible to pass edition and other flags in; I removed it because I thought it added no value.

Copy link
Contributor

Choose a reason for hiding this comment

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

It will error if you're using a version that doesn't know about certain idioms, i.e. 2018 will bail on let Some(foo) = bar else {..};

))
})?;
Ok(signature)
}
Copy link
Contributor

@drahnr drahnr Mar 3, 2026

Choose a reason for hiding this comment

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

Minor stylistic proposal: I find it a bit awkward to have many traits, I'd rather have a single, parameterized trait that uses a concrete type as parameter:

impl WireCodec for MySuperDuperInteraction {
type Input = ..;
type Output = ..;
fn decode_input(grpc::input::Type) -> Result<Self::Input> {
..
}
fn encode_output(Self::Output) ->  Result<grpc::output::Type> {
..
}
}


impl GrpcInteraction<MySuperDuperInteraction> for ValidatorServer {
    async fn handle(&self, input: MySuperDuperInteraction::Input) -> tonic::Result<Self::Output> {
..
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agree, we can achieve the same separation of concerns with fewer generated types

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not quite understanding why you think it will result in less types? You can either use more traits, and slightly less traits and many structs and impls. The trait variant is much less code, and less indirection imo.

But maybe I've missed an approach? @SantiagoPittella I don't quite follow your proc macro suggestion -- on what do you place the proc macro?

@drahnr I actually had your suggestion implemented here, in commit d9fd24.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

But I agree simplifying would be great. Create a new thread in one of the files with a sketch of the code you want to generate, and we can hash it out

fn generate_mod_rs(dst_dir: impl AsRef<Path>) -> std::io::Result<()> {
let mod_filepath = dst_dir.as_ref().join("mod.rs");
// I couldn't find any `codegen::` function for `mod <module>;`, so we generate it manually.
let mut modules = Vec::new();
Copy link
Contributor

Choose a reason for hiding this comment

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

There should be Module::new("we_are_here").get_or_new_module("foo")

@SantiagoPittella
Copy link
Collaborator

I like how methods can be added and implemented with this approach, but the code generation part is difficult to follow. I have some doubts about maintainability of this, though on the other hand I assume that this is not supposed to be changed frequently. This changes + an extensive explanation of the generation should do the trick IMO.

Cab probably achieve similar results without the codegen part, defining a single trait like GrpcMethod<M> + a proc macro:

  /// Marker trait for gRPC method descriptors.
  trait MethodDescriptor {
      type Request;
      type Response;
  }

  /// The standard decode -> handle -> encode pipeline.
  #[tonic::async_trait]
  trait GrpcMethod<M: MethodDescriptor>: Send + Sync {
      type Input;
      type Output;

      fn decode(request: M::Request) -> tonic::Result<Self::Input>;
      fn encode(output: Self::Output) -> tonic::Result<M::Response>;
      async fn handle(&self, input: Self::Input) -> tonic::Result<Self::Output>;

      async fn execute(&self, request: M::Request) -> tonic::Result<M::Response> {
          let input = Self::decode(request)?;
          let output = self.handle(input).await?;
          Self::encode(output)
      }
  }

Also, I'm not sure how streams will be accommodated into this pipeline.

@sergerad
Copy link
Collaborator

sergerad commented Mar 4, 2026

@Mirko-von-Leipzig I suppose this relates to this issue #1528 (comment)

Have you thought about how it might impact the ConversionError? Should we hold off on the ConversionError conversation while this poc is going on?

@Mirko-von-Leipzig
Copy link
Collaborator Author

@sergerad yeah this overlaps -- but I think we can begin working on that in parallel if we want. I've given some more thought to the API I'd like -- but I don't actually know how to achieve it. I'll write that down in the issue.

@Mirko-von-Leipzig
Copy link
Collaborator Author

Also, I'm not sure how streams will be accommodated into this pipeline.

We have a couple of options; it's not much more complex than the normal method - we just need to decide what to do with the stream type.

The tonic adds an associated stream type for each streaming method e.g.

trait tonic::BlockProducerApi {
    type MempoolSubscriptionStream: tonic::codegen::tokio_stream::Stream<
         Item = tonic::Result<MempoolEvent>
     > + Send + Sync + 'static;

     async fn mempool_subscription(request: ...) -> tonic::Result<Self::MempoolSubscriptonStream>);
}

Some options:

  1. Hardcode the associated type to Pin<Box<dyn Stream>> and let the method return any stream
  2. Have the method trait also contain an additional associated stream type which gets passed along

@drahnr
Copy link
Contributor

drahnr commented Mar 10, 2026

I am in favor of splitting the concerns of type mapping to be split from the RPC endpoint, which would allow re-use of the encoding across types. I'd propose two new traits.

This is so boring it's hardly worth typing it out. The question to me is how we want to pass the in

pub trait WireCodecIn {
    type GrpcInput;
    fn decode_input(input: Self::GrpcInput) -> tonic::Result<Self>;
}
pub trait WireCodecOut {
    type GrpcOutput;
    fn encode_output(output: Self) -> tonic::Result<Self::GrpcOutput>;
}

pub trait GrpcInteraction<Input: WireCodecIn, Output: WireCodecOut> {
    async fn handle(&self, input: Input) -> tonic::Result<Output>;

    async fn full(&self, request: <M as WireCodecIn>::GrpcInput) -> tonic::Result<<M as WireCodecOut>::GrpcOutput> {
        let input = <Input as WireCodecIn>::decode_input(request)?;
        let output = self.handle(input).await?;
        <Output as WireCodecOut>::encode_output(output)
    }
}

pub struct RpcEndpointChicken;

impl GrpcInteraction<FetchEggs, SomeOrNone> for RpcEndpointChicken {
    async fn handle(&self, input: Input) -> tonic::Result<Output> {
    ..
    }
}

What I do like about it is the modularity and the potential to opt-in to custom impls of pieces of it.

@drahnr
Copy link
Contributor

drahnr commented Mar 10, 2026

Re streaming:

I don't have a preference. Being able to inject input streams/mocking should be a concern, but I am not convince the protobuf layer is the right layer of mocking.

@Mirko-von-Leipzig
Copy link
Collaborator Author

I am in favor of splitting the concerns of type mapping to be split from the RPC endpoint, which would allow re-use of the encoding across types. I'd propose two new traits.

This is so boring it's hardly worth typing it out. The question to me is how we want to pass the in

pub trait WireCodecIn {
    type GrpcInput;
    fn decode_input(input: Self::GrpcInput) -> tonic::Result<Self>;
}
pub trait WireCodecOut {
    type GrpcOutput;
    fn encode_output(output: Self) -> tonic::Result<Self::GrpcOutput>;
}

pub trait GrpcInteraction<Input: WireCodecIn, Output: WireCodecOut> {
    async fn handle(&self, input: Input) -> tonic::Result<Output>;

    async fn full(&self, request: <M as WireCodecIn>::GrpcInput) -> tonic::Result<<M as WireCodecOut>::GrpcOutput> {
        let input = <Input as WireCodecIn>::decode_input(request)?;
        let output = self.handle(input).await?;
        <Output as WireCodecOut>::encode_output(output)
    }
}

pub struct RpcEndpointChicken;

impl GrpcInteraction<FetchEggs, SomeOrNone> for RpcEndpointChicken {
    async fn handle(&self, input: Input) -> tonic::Result<Output> {
    ..
    }
}

What I do like about it is the modularity and the potential to opt-in to custom impls of pieces of it.

I had pretty much exactly that here, in commit d9fd24.

The intention is to have the codec stuff reuseable, but having tried it out I think having the GrpcInteraction is more harmful than helpful. Given trait GrpcInteraction<T> vs trait T, I much prefer the latter (more traits, less marker structs).

@drahnr
Copy link
Contributor

drahnr commented Mar 10, 2026

I am a sucker for marker structs, so I'd favor the above, focusing on the pattern across RPC calls, plugging types.
Otherwise I think we should merge this rather sooner than later.

@SantiagoPittella
Copy link
Collaborator

I am a sucker for marker structs, so I'd favor the above, focusing on the pattern across RPC calls, plugging types.
Otherwise I think we should merge this rather sooner than later.

Agree.

Also, this is a great fit for the GrpcError macro that I introduced some time ago to avoid the manual .map_err(|err| tonic::Status::invalid_argument(...)) calls. This should help reduce impls.

We could do something like:

  trait SignBlock {
      type Input;
      type Output;
      type Error: Into<tonic::Status>; 

      fn decode(request: proto::ProposedBlock) -> Result<Self::Input, Self::Error>;
      fn encode(output: Self::Output) -> tonic::Result<proto::BlockSignature>;
      async fn handle(&self, input: Self::Input) -> Result<Self::Output, Self::Error>;

      async fn full(&self, request: proto::ProposedBlock) -> tonic::Result<proto::BlockSignature> {
          let input = Self::decode(request).map_err(Into::into)?;
          let output = self.handle(input).await.map_err(Into::into)?;
          Self::encode(output)
      }
  }

@Mirko-von-Leipzig
Copy link
Collaborator Author

Re: marker structs - I also like them; which is why the first attempt used them. However after toying with it for a while I had some realisations.

imo the strength of marker structs is that you can define one main trait and then get a bunch of stuff for free with the generic markers. So you can define a struct and get the trait impl for free with much less boilerplate. This advantage doesn't really apply here though, because we're code generating it all.

The marker structs have some downsides:

  • more complex codegen code
  • more complex trait usage i.e. trait ServiceTrait: MethodA vs trait ServiceTrait: Method<a> where A: ....
  • you can't override the trait impls because you only have access to the marker
  • more code in general
  • you can't document the methods
    • we can codegen docs for trait MethodA, but not for Method<A>

As a summary: we use generics + markers to reduce code duplication and boilerplate as a form of codegen. However, we've already got actual codegen to do this.

I was sort of on the fence before, but I'm almost solidly against this now 😅


Regarding the codec stuff.. I think we definitely want a trait GrpcEncode and trait GrpcDecode. But that will be much more work and may take a while so I'd avoid coupling that with the method trait. The encode and decode methods can evolve to simply call the type codecs directly once we have them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants