2.5. Remote Procedure Call

Journal replication can be used to implement concurrency over a network connection, but it has drawbacks:

  • All clients have to download the whole database, which does not scale.

  • The server may have to wait for a disconnected client to time out, which hurts availability.

These scalability and availability issues can be solved by using remote procedure calls instead. With this mechanism, a disconnection can never leave a stale lock, and there is no need for clients to download the whole database. Each client can upload new data to the server in a very minimal single round-trip. A write transaction will be executed entirely on the server, so its duration is not affected by network latency, and it cannot be stalled by a disconnection.

Joedb can be used as a binary message serialization format for remote procedure calls. From a list of functions that take a writable database as parameter, the joedb compiler can generate code for a client and a server that allow executing these functions remotely.

2.5.1. Tutorial Example

This example implements 4 procedures for the joedb tutorial database:

  • insert_city takes a string parameter and inserts it as a city name in the city table.

  • delete_city takes a string parameter and deletes the city from the city table.

  • get_population takes a list of cities as parameter, and returns the number of persons for each city.

  • get_inhabitants takes a city name as parameter, and returns the list of persons living in this city

First a Service class has to be implemented to run the procedures on the server:

./tutorial/src/tutorial.rpc/Service.h
#ifndef tutorial_rpc_Service_declared
#define tutorial_rpc_Service_declared

#include "tutorial/Client.h"
#include "tutorial/rpc/city/Writable_Database.h"
#include "tutorial/rpc/population/Writable_Database.h"

namespace tutorial::rpc
{
 /// A collection of procedures that will be executed in the rpc server
 ///
 /// joedbc uses a regular expression to find procedures in the file.
 /// The constructor is not called from compiled code, so it can have any
 /// signature. In particular, a Service does not necessarily have to
 /// access a database at all.
 class Service
 {
  private:
   Client &client;

  public:
   Service(Client &client): client(client)
   {
   }

   /// Insert a city from a name string
   void insert_city(city::Writable_Database &city) 
   {
    client.transaction
    (
     [&city](Writable_Database &db)
     {
      const auto city_id = db.find_city_by_name(city.get_name());
      if (city_id.is_null())
       db.new_city(city.get_name());
      else
       throw joedb::Exception("city already exists");
     }
    );
   }

   /// Delete a city from a name string
   void delete_city(city::Writable_Database &city) 
   {
    client.transaction
    (
     [&city](Writable_Database &db)
     {
      const auto city_id = db.find_city_by_name(city.get_name());
      if (city_id.is_not_null())
       db.delete_city(city_id);
      else
       throw joedb::Exception("city does not exist");
     }
    );
   }

   /// A procedure can return values by writing them to the message database
   void get_population(population::Writable_Database &population)
   {
    const auto &db = client.get_database();

    for (const auto data: population.get_data_table())
    {
     const std::string &city_name = population.get_city_name(data);
     const id_of_city city = db.find_city_by_name(city_name);
  
     int64_t N = 0;
  
     if (city.is_not_null())
      for (const auto person: db.get_person_table())
       if (db.get_home(person) == city)
        N++;
  
     population.set_city(data, city);
     population.set_population(data, N);
    }
   }

   /// A message can have the same schema as the main database
   void get_inhabitants(tutorial::Writable_Database &message)
   {
    const auto message_city = message.get_city_table().first();

    if (message_city.is_not_null())
    {
     const auto &db = client.get_database();

     const auto city = db.find_city_by_name(message.get_name(message_city));
     if (city.is_not_null())
     {
      for (const auto person: db.get_person_table())
      {
       if (db.get_home(person) == city)
       {
        message.new_person
        (
         db.get_first_name(person),
         db.get_last_name(person),
         db.get_home(person)
        );
       }
      }
     }
    }
   }
 };
}

#endif

Then a channel can be used to connect a client to the server:

/tutorial/src/rpc_client.cpp
#include "joedb/concurrency/Local_Channel.h"
#include "joedb/ui/main_wrapper.h"
#include "tutorial/rpc/Client.h"
#include "tutorial/rpc/population/print_table.h"
#include "tutorial/print_table.h"

#include <iostream>

namespace joedb
{
 static int rpc_client(Arguments &arguments)
 {
  const std::string_view endpoint_path = arguments.get_next("<endpoint_path>");

  if (arguments.missing())
  {
   arguments.print_help(std::cerr);
   return 1;
  }

  Local_Channel channel((std::string(endpoint_path)));
  tutorial::rpc::Client rpc_client(channel);

  {
   tutorial::rpc::city::Memory_Database city;
   city.set_name("Tombouctou");
   rpc_client.insert_city(city);

   try
   {
    rpc_client.insert_city(city);
   }
   catch (std::exception &e)
   {
    std::cout << "Caught exception: " << e.what() << '\n';
   }

   rpc_client.delete_city(city);
  }

  {
   tutorial::rpc::population::Memory_Database population;
   population.set_city_name(population.new_data(), "Tokyo");
   population.set_city_name(population.new_data(), "Tombouctou");
   population.set_city_name(population.new_data(), "Lille");
   rpc_client.get_population(population);
   tutorial::rpc::population::print_data_table(std::cout, population);
  }

  {
   tutorial::Memory_Database db;
   db.set_name(db.new_city(), "Lille");
   rpc_client.get_inhabitants(db);
   tutorial::print_person_table(std::cout, db);
  }

  return 0;
 }
}

int main(int argc, char **argv)
{
 return joedb::main_wrapper(joedb::rpc_client, argc, argv);
}

Here are the schema definitions for the procedure messages:

./tutorial/src/tutorial.rpc/city.joedbi
create_table city
add_field city name string
./tutorial/src/tutorial.rpc/city.joedbc
namespace tutorial::rpc::city
set_single_row city true
./tutorial/src/tutorial.rpc/population.joedbi
# "city" is the name of a table in the main schema.
# joedbc will generate code so that id_of_city is the same type for both, ie:
# using id_of_city = tutorial::id_of_city;
# instead of definining a new different population::id_of_city type.
# This allows easily referring to the main schema from procedure schemas.
create_table city

# This is the table for exchaning data between the client and the server.
# It will be used both for input and output.
# The client will fill input fields and push these changes to the server,
# then the server will push output fields to the client.
# A procedure can use any arbitrarily complex schema: multiple tables, etc.
create_table data

# input
add_field data city_name string

# output
add_field data city references city
add_field data population int64
./tutorial/src/tutorial.rpc/population.joedbc
namespace tutorial::rpc::population