A Client for the Runner

The handler for Nadia’s POST should call the runner the way it called runSubmission, without building JSON or reading HTTP responses. Every call to the runner takes the same steps: build a request object, send it in a POST, read the response object, and check for error. We write these steps once, in a function:

async function call(method, params) {
  const response = await fetch("http://runner.codedrill.internal/rpc", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ jsonrpc: "2.0", method, params, id: nextId++ }),
  });
  const message = await response.json();
  if (message.error) {
    throw new RunnerError(message.error.code, message.error.message);
  }
  return message.result;
}

nextId is a counter in the application. Each call gets a new number from it.

Then we write a function in the application for the runner’s procedure. It has the same name and arguments:

const runner = {
  runSubmission: (language, code, tests) => call("runSubmission", { language, code, tests }),
};

Now the handler for Nadia’s POST calls the runner almost the way it called runSubmission in the monolith:

const result = await runner.runSubmission("java", code, problem.tests);

The one difference is await: the handler now waits for a network round trip.

runner.runSubmission is a client stub. It stands in for the remote procedure: it has the same name and arguments, and the application calls it like any local function. The stub packs the arguments into params and passes them to call, which sends the request object and turns the response object back into a return value. An error in the response object becomes an exception. A failed local call throws an exception in the same way.

We could wrap a REST request in a function the same way, and many client libraries for REST APIs do. So the stub is not what makes this RPC. Over HTTP, a call to the runner is still an HTTP request. The difference is in the API. The runner’s API is a set of procedures, and every call has the same shape: a name, arguments, and a result or an error. HTTP only carries the call, so every call is a POST to /rpc. That is why one function, call, works for every procedure. A wrapper around a REST API has to know, for each endpoint, which method to send and which path to build.

On the runner

On the runner, an object called procedures maps the name of each procedure to the function that runs it:

const procedures = {
  runSubmission: ({ language, code, tests }) => runSubmission(language, code, tests),
};

The runner’s one handler, for POST /rpc, looks up method in procedures and calls the procedure with params. If there is no procedure with that name, it sends a response object with the error code -32601. The handler and procedures are the server stub: they unpack the request object, call the real procedure, and pack its return value into a response object.

The function on the right is the same runSubmission that ran inside the monolith. It now runs on the runner, and it runs the code in a container. It is the only part of the runner’s code that we write for this procedure. Adding a procedure to the runner means adding an entry to procedures on the runner and to runner in the application.

This code is simplified. It does not check the HTTP status, it has no limit on how long it waits, and the runner’s handler does not check the arguments or catch an exception. In practice, we do not write this code by hand. A JSON-RPC library provides the code that sends and receives calls, and with gRPC, both stubs are generated from a description of the interface. We wrote them here to show what they do.