Designing an unreliable RPC framework in Rust

Designing an unreliable RPC framework in Rust

My latest side project is a distributed application with real-time features. A single authoritative server contains the system-wide information used by a few clients to run the application logic in sync. Nodes are required to exchange time-sensitive data at fixed intervals: information must arrive on time and expire as soon as a new message is received.

In this setting, I want a lightweight, fast, and versatile communication protocol that doesn't get in the way. UDP is arguably a better choice over TCP for live data, as I can eliminate all the overhead related to reliable deliveries: in the context of my use case ACKs and retries are redundant since the data would be stale anyway if received late.

However, I still wanted some clear boundaries between bare sockets and my application logic, plus adding some quality-of-life features like encoding, entity schemas, and broadcasting. In practice, my desires led me to create a wrapper around UDP connections and I diverted from the original side-project idea to implement an unreliable Remote Procedure Call framework: uRPC.

Loosely inspired by gRPC, my homemade micro-framework provides methods and types to handle best-effort delivery. Instantiating a server looks like this:

let mut server = UrpcServer::new(SRV_ADDR);
server.register("donut-service", DonutService::new());
UrpcServer::start(server);

Multiple services can be instantiated here, as long as they implement the expected UrpcService interface which is used to dispatch incoming requests to the correct service. Of course, a lower layer component such as a database handler can be set here by using Dependency Injection, as long as it implements the Sync trait.

The client is equally straightforward:

// Define the list of recipient nodes and instantiate the client
let recipients = vec![DONUT_ADDR];
let client = UrpcClient::new(recipients);


// Let's build the request
let request = DonutRequest {
  quantity: 3,
  type: "chocolate",
}

// The client encodes the request payload and sends to each recipient.
// Delivery is not guaranteed.
client.send("donut-service", "order", request);

Nodes that interact with uRPC will need to share a schema (e.g. a common definition for a DonutRequest) which will be serialized according to a given encoding.

Possible improvements

There are a number of improvements that can be applied to uRPC at this point:

  • Removing unnecessary boilerplate code. For instance, the dispatch logic is a trivial pattern-matching block that could be generated automatically, but I still don't know which Rust features could allow it;
  • Adding support for custom middleware logic, even if it could be added easily;
  • Streaming data;
  • The datagram size is fixed at 1024 bytes and bigger payloads are not supported
  • The default encoding is JSON, which can take up a lot of unnecessary space. I will evaluate a binary encoding in the future, like MessagePack which is already supported by serde

Reference

Github repo: https://github.com/Ipanov7/urpc