Skip to content
Matthew Conover edited this page Apr 7, 2022 · 6 revisions

Use Bebop to define RPC interfaces and communication using Bebop-serialize data!

Bebop RPC is a transport-agnostic, code-generation system designed to integrate tightly with Bebop data schemas and reduce boilerplate and potential implementation discrepancies.

The focus is on speed and flexibility. To achieve this we have kept a simple feature set which covers roughly 90% of use cases.

At the time of writing, this is only supported for Rust and TypeScript but support for C#, and C++ are planned.

You can see more information about the original plans and current status in #177.

Features

  • Transport agnostic: Can easily be built to run on TCP, UDP, Websockets, IPC, Channels, ...; further, if you want to compress data, encrypt data, or some other manipulation on the raw stream, this can be performed easily.
  • Mostly Stateless: The server only needs to keep track of who it is responding to for each request, and the client only needs to keep track of what requests it has in flight. Bebop does not care what data has already been sent between clients. This means requests could get routed to different servers using some form of load balancing.
  • P2P: Both ends of a connection may be open to request, but may also expose different services. In this way you could allow the server to query state from the client OR have to peers which coordinate as equals. You could also have one define a NullService and stick to classic one-directional requests.
  • Timeouts and deadlines: Every request may specify a deadline, this makes it easy to prevent memory leaks when using unreliable transports.
  • Call Signature Validation: Each request must pass a simple signature validation which prevents incompatible API versions from accidentally mixing. The signature is based on the arguments and return values and not the name or id. These are calculated at the time of schema compilation and are trivially checked during operation. They also provide partial-upgrade protection when load-balancing requests or in P2P networks.

Defining a Service

A Bebop service defines all functions which are available on one end of transport. Some other RPC systems encourage defining multiple different services that one microservice may answer to, but with Bebop you would write one service assuming all requests are being handled on the same port/channel.

In the future we may support composition of service if this becomes a burden.

All requests are in the form of call-response—the transport may break datagrams into multiple packets, but at the RPC level they are always considered as a single unit. Streaming is not supported, and if you need to create streams the recommendation is to use Bebop RPC to coordinate the creation of a socket or channel upon which to send bebop types.

Definition

Arguments may be any record type, however, it is recommended to use re-usable structs if you find multiple functions are using the same arguments. Parameters are not allowed to be optional at this time, so for optional arguments wrap with a message type.

Valid return types are any single type, or void. Note that void returns will still send a completion notice and therefore requests to "non-returning" functions may still await their completion.

All services will automatically have a 0 -> string serviceName() function defined. This means you may always cal the remote to check what service is responding to queries even if it may be different from the expected.

Versioning

All functions are given a call signatures at bebop compilation based on their arguments and return types. These mostly replace the need for versioning, allowing different version to communicate contingent on their inputs/outputs rather than arbitrarily defined version codes that may not get updated by accident (or be updated when not needed).

Of course, you may optionally define an additional function to get the service version if this is important to your use case. Another benefit to call signatures is they are resilient to partial updates with a load-balanced setup since every request includes the expected signature.

Conceptually, the function opcode should define the intent of what the function does, and the call signature should define the implementation details around how it does it. If you want to change the intent of an operation, it is appropriate to assign a new opcode and deprecate the old function. However, if a function changes requirements but continues to do the same thing conceptually, a new opcode is only required if you want to maintain backwards compatability.

Example

For the examples provided, let us consider a really simple Key-Value store which is basically just a HashMap<String, String> and wishes to expose functionality over a network/channel or other.

/** A key-value pair. */
struct KV {
    string key;
    string value;
}

/**
 * A no-op service for the client as this example is a classic client/server setup and not P2P.
 * For P2P simply define another service with functionality OR use the same service for both.
 */
service NullService {}

/**
 * A service which stores keys and values like a HashMap.
 * 
 * Some variations on this could be instead of storing String values, storing a struct like
 * 
 * union StoredData {
 *   1 -> struct OptA {...};
 *   2 -> struct OptB {...};
 *   ...
 * }
 */
service KVStore {
    /** Retrieve a paginated list of key-value pairs. */
    1 -> KV[] entries(uint64 page, uint16 pageSize);
    
    /** Returns a paginated list of keys. */
    2 -> string[] keys(uint64 page, uint16 pageSize);
    
    /** Returns a paginated list of values. */
    3 -> string[] values(uint64 page, uint16 pageSize);

    /** Returns true if it was inserted, false if the key was a duplicate and was not inserted. */
    4 -> bool insert(string key, string value);
    
    /** Add multiple entries and return any keys which where already there */
    5 -> string[] insertMany(KV[] entries);
    
    /** Lookup a key. Returns a custom user error of not present. */
    6 -> string get(string key);

    /** Retrieve a count of the number of entries stored. */
    7 -> uint64 count();
}

Implementations

There are four main components in the Rust implementation:

  • Router: The glue that connects the local, remote, and the transport. You need one of these for each transport connection.

  • TransportProtocol: How datagrams get sent/received. Client-side load balancing may optionally be built into this component or compression or encryption and so on. Responsible for gracefully handling reconnects in the event of issues.

  • Requests: Functions that can be called on the remote. Requests are what a Client uses. These are generated entirely by Bebop and handle packing request data and unpacking response data.

  • Handlers: Functions that can be called by the remote. Handlers are what a Server implements to "handle" or "serve" requests. This is an interface or abstract class that must be implemented.

Note: Because Bebop RPC is P2P—both sides of every connection must define a Service and Handlers, however, in a classic client-server set up the client would specify NullServiceHandlers and the server would specify NullServiceRequests. NullService does not come pre-defined but is trivial to add to your schema; see the above example.

Deadlines and Timeouts

A deadline refers to how long a handler has to operate, and a timeout refers to how long a requester is willing to wait. The terms get used somewhat interchangeably, but technically a deadline is an Instant in time and a timeout is a Duration.

Requesters submit a timeout (duration) in seconds that they are willing to wait from the moment of sending the request (thus setting a deadline on their end), and handlers calculate a deadline (instant) from when that packet is received from the transport. There is some optimism on the side of handler that the requester may still accept the packet slightly late. We do it this way to remove any assumption of time synchronization between the peers and assume only that time passes at very close to the same rate for both. No traveling at relativistic speeds, mkay?

Deadlines are necessary when using unreliable transport or when working with time-sensitive data where, after a certain point in time, the requester will no longer care about the response. Handlers which have a long operation should check to make sure they have not passed the deadline to prevent doing unnecessary work.

No guarantees are written into Bebop about the whether a mutating operation will have completed if the operation times out. You must choose how to implement this yourself. Further, it is important to remember than a timeouts may occur between sending the data and the client receiving it. Fundamentally: A timeout response does not mean that an operation did not complete.

For these reasons, if you need to ensure a response is received on successful completion, use a reliable transport without deadlines/timeouts.

Rust

There is an example that we use for testing which can be found in the laboratory. This includes documentation about oddities of using the handlers macro and also an example of writing against the un-modified, callback version.