181 lines
8.5 KiB
Markdown
181 lines
8.5 KiB
Markdown
|
|
# 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.
|