Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Interfaces Are Data

A #[service] interface is more than a typed handle on top of RequestStream. It is a value, clone-able, sharable, and once we wire up serialization, transmittable over the wire. Two processes that both speak Calculator can exchange a single JSON blob to move from “I have a server running here” to “I am a client of that server.” Cluster members publish their interfaces. Coordinators receive them. Clients reconstruct method endpoints without ever calling a discovery RPC.

This is FoundationDB’s pattern (fdbrpc.h, section 7 of the layered analysis at docs/analysis/foundationdb/layer-3-fdbrpc.md). When master boots, it serializes its MasterInterface into the cluster status. Any worker that reads the status reconstructs the same set of RequestStream endpoints and starts calling them. The whole interface, N methods, moves as a fixed-size payload regardless of N.

What Travels and What Reconstructs

The interface struct generated by #[service] carries one InterfaceMethod<Req, Resp> per trait method, plus a hidden base_token: UID. On the wire, only two things appear:

{ "address": "10.0.0.1:4500", "base_token": { "first": 12345, "second": 67890 } }

Every method endpoint is reconstructed locally. The first method gets base_token.adjusted(1), the second gets base_token.adjusted(2), and so on. UID::adjusted is a fixed offset on a 128-bit identifier, so the math is reversible and collision-free across services that get distinct base tokens.

For a four-method Calculator, this is the difference between shipping one address plus one UID versus four addresses plus four UIDs. Multiply by the number of interfaces in a MasterInterface or StorageServerInterface and the savings get real.

The Codec Generic Problem

Standard serde::Deserialize cannot reconstruct an interface alone. Each method’s ServiceEndpoint<Req, Resp> needs encode and decode function pointers that capture the codec, and the codec is a generic on NetTransport<P, C>. A Deserialize impl is non-generic by design. There is no way to pass C into the deserialize call.

The fix is to make the entry point explicit:

#![allow(unused)]
fn main() {
let mut de = serde_json::Deserializer::from_slice(&bytes);
let calc = Calculator::deserialize_with(&transport, &mut de)?;
}

deserialize_with is generic over the deserializer and over the transport’s P and C. It reads the same { address, base_token } shape that the matching Serialize impl writes, then calls the existing from_base constructor, the same one that adjusts each method endpoint by its index.

The serialize side stays plain. serde_json::to_vec(&calc) works because writing does not need the codec, only the address and the base token.

End-to-End Round-Trip

A server publishes its interface as JSON. Anything that reads the JSON, another process, a status registry, a test, can reconstruct a working remote-mode interface and start calling methods.

#![allow(unused)]
fn main() {
#[service]
trait Calculator {
    async fn add(&self, req: AddRequest) -> Result<AddResponse, RpcError>;
    async fn sub(&self, req: SubRequest) -> Result<SubResponse, RpcError>;
}

// Server
let calc = Calculator::init(&transport);
let bytes = serde_json::to_vec(&calc)?;
publish(bytes); // to a coordinator, status doc, file, channel...

// Client (possibly a different process)
let mut de = serde_json::Deserializer::from_slice(&bytes);
let calc = Calculator::deserialize_with(&transport, &mut de)?;

let response = calc.add.get_reply(AddRequest { a: 2, b: 3 }).await?;
}

The calc on the client side is the same struct as on the server, but every add and sub field is now in remote mode. is_remote() returns true, recv() would panic, and get_reply() actually goes over the wire.

Why This Matters for Simulation

When the test driver wants to talk to a process, it does not need a special handshake protocol. The process publishes its interface to a shared registry. The workload pulls bytes out and deserializes. The rest is normal RPC, runs through the normal failure monitor, and gets exercised by the same chaos engine that touches real traffic.

The wire format is also small enough to log. A failing seed can replay the exact JSON that crossed the boundary at registration time, which means interface discovery is just another deterministic event in the trace. There is nothing magical happening behind the scenes. Interfaces are data, and data flows through simulation the same way as any other byte stream.