# Runtime RPC versioning implementation notes This document describes the versioning protocol used to ensure two-way compatibility between different versions of the API. The approach is loosely inspired by the `Both_converts` model used by [Versioned_rpc](https://ocaml.janestreet.com/ocaml-core/latest/doc/async_rpc_kernel/Async_rpc_kernel/index.html#module-Versioned_rpc) in `Async`, in which both parties maintain a "menu" of supported RPC versions, which is used to negotiate a common protocol for each method. This is a working document and will be updated as the design evolves. ## Terms - A **procedure** is a common term encompassing *notifications* (one-way messages) and *requests* (a communication to which a response is expected). - A **model** is the logical payload type for one direction of a procedure. Note that this is a per-actor entity; the ultimate goal of runtime RPC versioning is to allow clients and servers to disagree on a model type without preventing them from interacting. - A **wire type** for a procedure is the logical type sent "over the wire" for one direction of a procedure. The end result of negotiating a version for a procedure is to select a wire type known to both the client and the server. Typically, the older of the two model types will be chosen as the wire type, - A **generation** of a procedure is the set of wire types corresponding to each direction of a procedure, along with the de/serialisation logic and upgrade and downgrade functions transforming the wire types to the model types and vice versa. Each generation is associated with a *version number*, which should be unique within a procedure. - The **menu** is a mapping from method names to the particular generations that will be used for each procedure for a particular session. In the source code, this term is overloaded to also refer to a mapping from method names to *all known* generations of a procedure. - The **declaration** of a procedure lists its model types and all known generations, along with its method name. Multiple declarations of the same procedure is allowed, so long as they do not overlap version numbers. - The **implementation** of a declaration is the actual behavior of a procedure, which acts on the model types. Typically, this will be on the server, but in the future there may also be a use for server-to-client requests. This document is not concerned with the internals of any given implementation, only whether such an implementation exists at all. ## Background Previously, there was no distinction between model and wire types. This meant that any change to a model type required both build servers and clients to upgrade in lockstep, as otherwise the receiver would be unable to deserialize the payload of a procedure. Unfortunately, most lighter-weight solutions (such as modifying the de/serialization logic to be resilient to, e.g., extra/missing fields or variants in types) are insufficient. Early designs of the diagnostic API, for example, reported targets as strings, but was changed to give structured information instead. Similarly, requirements like "the client must always be older than the server" (or the reverse) don't work in environments like Jane Street, where the same editor plugin must be able to interact seamlessly with multiple iterations of Dune (which may be older or newer than the editor plugin itself). The main goal of the system, then, is to ensure that both the server and client applications can be programmed against the current model types for each procedure, with all backwards- or forwards- conversions happening under the hood. ## Protocol At session initialization time, the client will first send an initialization request to the server containing a single version number corresponding to the overall RPC version the client will use. If this number is determined to be versioning-compatible (see [Session versioning](#session-versioning)), the server will respond with a token instructing the client to initiate version negotiation. Otherwise, the server will respond with an error. Upon receiving this token, the client will initiate version negotiation by sending a list of `(method-name, generations)` pairs, where `method-name` is the name of each declared procedure, and `generations` is the list of version numbers for that procedure's generations. Upon receiving a list of supported versions from the client, the server will compare it to its list of *implemented* versions, selecting the greatest common generation for each procedure. If the client and server do not share any generations for a procedure, it is omitted entirely. If there is at least one method for which a common version exists, then the server responds with a list of `(method-name, selected-version)` pairs, where `selected-version` is the version number of the greatest common generation. This list is then used by both parties to construct the version menu. Otherwise, if there are no common versions for any methods, an error is returned to the client and the session is invalidated. Note that we do not currently require declared/implemented versions to span a contiguous range of version numbers. This can have a few uses, such as preventing clients from using a known-bugged generation of a procedure. When executing a procedure, the sender first looks up the correct generation in the menu (see [Error handling](#error-handling)), and downgrades the payload from the sender-side model type to the wire type. Upon receipt, the server performs the same lookup to deserialize, then upgrade the payload to the receiver-side model type, then the procedure implementation is performed, producing a response in the case of requests. If necessary, the same transformations are then performed in reverse, sending the value back to the sender, completing the procedure. Barring strange circumstances (such as a client declaring a generation with a newer version number than the type exposed in `dune_rpc.mli`), it is always the case that the transformation from wire to model types will be the identity function on the side that is older. ## Miscellaneous implementation notes ### Session versioning In addition to version numbers existing for each procedure version, there are two further version numbers associated with the session as a whole which are sent as part of session initialization. The first is the version of Dune each side purports to be as a `MAJOR.MINOR` number (serialized as an `int * int` pair). This is not currently checked. Next is a version of the initial handshake protocol to be used. This takes the form of a single `int`. In the future, if the initial negotiation protocol changes, this value can be adjusted and checked to account for this. ### Error handling Handling of versioning errors has become more complex, as we need to distinguish between "no such method exists" and "the server and client do not share any common generations for this method". Secondly, this means that the initiation of a procedure can now fail, which complicates one-way communications (for example, the server must swallow errors and clients must be upgraded to handle version errors on notifications, which were previously infallible). Finally, the versioning protocol itself must be either versioned separately or stabilised (see [Session versioning](#session-versioning)). ### Tweaks - We currently send the entire version menu from client to server and back twice, once for the client to inform the server of all supported versions, and again for the server to inform the client of the common versions. This can lead to large messages being passed at session initialization, which may become a performance bottleneck. - The size of the version negotiation messages is proportional to the number of all known generations for all procedures, which can be approximated by `number-of-procedures` times `number-of-supported-generations`. In practice, I do not expect this number to be large (I would be surprised if this number is ever on the order of 100). - One alternative is to perform per-procedure negotiation, where the initiator of a procedure first sends its known version ranges, the recipient sends the selected version (or an error), then the procedure proceeds as before. This approach trades startup and lookup overhead for a constant per-communication overhead. It also makes distinguishing "no such method exists" and "no common versions" simpler.