Say we want to implement an RPC service for basic maths operations. For example, let's calculate the value of ln(e^2). This calculation has several steps in our maths API:
- Get the value of e
- Square e
- Calculate the
ln
of the result
In a typical RPC-style server, each of those steps would be an HTTP request:
const math = getRPCClient();
const e = await math.e();
const e_squared = await math.pow({base: e, exp: 2});
const ln_e_squared = await math.log({arg: e_squared, base: e});
In order to cut down on HTTP round trips, we'd like to be able to send a whole expression to the server rather than one expression at a time.
JSON-RPC allows us to send batch requests to fit many calls into a single HTTP request, but that doesn't help us here because our calculations need to use the results of previous requests. 'Pipelining' would let us specify how to use results as inputs to requests in the same batch.
Example JSON-RPC request:
[
{"jsonrpc": "2.0", "method": "math.e", "id": "e_val"},
{"jsonrpc": "2.0", "method": "math.pow", "params": {"base": "$e_val", "exp": 2}, "id": "e_squared"},
{"jsonrpc": "2.0", "method": "math.log", "params": {"arg": "$e_squared", "base": "$e_val"}, "id": "ln_e_squared"}
]
and response:
[
{"jsonrpc": "2.0", "result": 2.718281828459, "id": "e_val"},
{"jsonrpc": "2.0", "result": 7.389056098930, "id": "e_squared"},
{"jsonrpc": "2.0", "result": 2, "id": "ln_e_squared"}
]
Note the:
- Use of request results as params by prefixing their IDs with
$
- Use of
"$e_val"
where a number was expected inmath.pow
- Reuse of
e_val
in calls to bothmath.pow
andmath.log
The client should be able to send requests in any order inside the batch, and the server will resolve the dependency ordering between their variables.
In order to take maximum advantage of pipelining, it'd be nice to abstract the process of creating request IDs and constructing the batch request. A DSL based on ES6 Proxies or generated code could provide a very readable interface.
This would be nice:
const math = getRPCClient();
const e = math.e(); // e is now some kind of Promise
const e_squared = math.pow({base: e, exp: 2});
const ln_e_squared = math.ln({arg: e_squared, base: e});
// the client doesn't actually send the request until you call get()
ln_e_squared.get().then(ln_e_squared => console.log(ln_e_squared === 2));
// not sure if the name shadowing is good style or not
// the intermediate values are Promises too, so this works:
Promise.all([e, e_squared]).then(...)
// but is there a more elegant way to get intermediate results? how about:
ln_e_squared.get({e, e_squared}).then((result, {e, e_squared}) => ...);
// or similarly:
ln_e_squared.get().then((result, results) => console.log(results.get(e)));
// results.get looks up the uid of e, generated when e was created
// or what if we automatically filled in the results after get()?
ln_e_squared.get().then(() => console.log(e.result));
// I don't like this heaps because it relies on the values being mutable
// on the other hand it's sort of just like memoisation, if functions are pure
// here's an example where that may go wrong:
const rand = math.random();
const rand_0_to_10 = math.mul([rand, 10])
rand_0_to_10.get().then(() => console.log(rand.value));
rand_0_to_10.get().then(() => console.log(rand.value));
// are the two rand.values a race condition?
// actually probs not thanks to JS being single-threaded
// BUT if we use rand.value outside those callbacks, then it's not well known
I'm a fan of jsonrpc, but I recently found myself looking for pipelining since it exists in cap'n'proto. Do you know if anyone's done anything like what you laid out?