2.7. Concurrency

Joedb offers mechanisms to access a single database from multiple processes on the same machine, or from remote machines over the network.

2.7.1. Principle

A joedb client is made of two parts: a file, and a connection. The file stores a replica of the database, and the connection is used for synchronization. A connection provides 3 operations:

  • pull: update the file with new journal entries from the connection.

  • lock_pull: get exclusive write access to the connection, and pull.

  • push_unlock: write new modifications of the file to the connection, and release the lock.

So it works a bit like git, with the significant difference that merging branches is not possible. History must be linear, and a central mutex is used to prevent branches from diverging.

Locking the central mutex is not strictly necessary: offline changes can be made to a local database without any synchronization with the remote server. It will still be possible to push those changes to the server when connecting later, but the push will succeed only if there is no conflict with any other write that may have occurred.

2.7.2. Example

The compiler produces code that ensures that locks and unlocks are correctly paired, and modifications to the database can only occur during a lock. This is done with transaction function that takes a lambda as parameter, and executes it between a lock_pull and a push_unlock.

#include "tutorial/Client.h"
#include "joedb/concurrency/File_Connection.h"
#include "joedb/journal/Memory_File.h"

/////////////////////////////////////////////////////////////////////////////
int main()
/////////////////////////////////////////////////////////////////////////////
{
 //
 // This sets up a configuration with a server and 2 clients.
 //
 joedb::Memory_File server_file;
 joedb::Memory_File client1_file;
 joedb::Memory_File client2_file;

 joedb::File_Connection connection(server_file);

 tutorial::Client client1(client1_file, connection);
 tutorial::Client client2(client2_file, connection);

 //
 // The databases are empty. client1 will add a few cities.
 //
 // All write operations are performed via the transaction function.
 // The transaction function takes a lambda as parameter.
 // The lock_pull operation is performed before the lambda, and the push_unlock
 // operation is performed after the lambda, if no exception was thrown.
 // If any exception was thrown during the lambda, then the changes
 // are not pushed to the connection, and the connection is unlocked.
 // Writes that occured in a transaction before an exception are not sent to
 // the connection, but they are written to the file.
 //
 client1.transaction([](tutorial::Generic_File_Database &db)
 {
  db.new_city("Paris");
  db.new_city("New York");
  db.new_city("Tokyo");
 });

 //
 // client1.get_database() gives a read-only access to the client file
 //
 std::cout << "Number of cities for client1: ";
 std::cout << client1.get_database().get_city_table().get_size() << '\n';

 //
 // Client1 added cities, and they were pushed to the central database.
 // They have not yet reached client2.
 //
 std::cout << "Number of cities for client2 before pulling: ";
 std::cout << client2.get_database().get_city_table().get_size() << '\n';

 //
 // Let's pull to update the database of client2
 //
 client2.pull();
 std::cout << "Number of cities for client2 after pulling: ";
 std::cout << client2.get_database().get_city_table().get_size() << '\n';

 return 0;
}

It produces this output:

Number of cities for client1: 3
Number of cities for client2 before pulling: 0
Number of cities for client2 after pulling: 3

2.7.3. Connections

The constructor of the tutorial::Client class takes two parameters: a file for storing the database journal, and a connection. The connection is an object of the Connection class, that provides the synchronization operations (pull, lock_pull, push_unlock). This section presents the different kinds of available connections.

2.7.3.1. Plain Connection

The Connection superclass does not connect to anything. Such a connection can be used in a client to handle concurrent access to a local file. If the file was opened with joedb::Open_Mode::shared_write, clients can start write transactions simultaneously, and the connection will use file locking to synchronize them.

joedbc produces a convenient Local_Client class that creates the connection and the client in a single line of code. Here is an example:

#include "tutorial/Local_Client.h"

#include "joedb/io/main_exception_catcher.h"

#include <chrono>
#include <thread>

/////////////////////////////////////////////////////////////////////////////
static int local_concurrency(int argc, char **argv)
/////////////////////////////////////////////////////////////////////////////
{
 tutorial::Local_Client client("local_concurrency.joedb");

 while (true)
 {
  client.transaction([](tutorial::Generic_File_Database &db)
  {
   db.new_person();
  });

  std::cout << "I have just added one person. Population: ";
  std::cout << client.get_database().get_person_table().get_size() << '\n';
  std::this_thread::sleep_for(std::chrono::seconds(1));
 }

 return 0;
}

/////////////////////////////////////////////////////////////////////////////
int main(int argc, char **argv)
/////////////////////////////////////////////////////////////////////////////
{
 return joedb::main_exception_catcher(local_concurrency, argc, argv);
}

Multiple instances of this program can safely write to the same database concurrently.

2.7.3.2. File_Connection

File_Connection creates a connection to a file. Here are some typical use cases:

  • File_Connection can be used to make a safe and clean copy of a database that contains a transaction that was not checkpointed, either because the database is currently being written to, or because of a previous crash.

  • File_Connection can be used to convert between different file formats. For instance, pushing a plain joedb file to a brotli Encoded_File will create a compressed database.

  • A File_Connection to an SFTP_File can be a convenient way to pull from a remote database without running a joedb server on the remote machine. Performance will be inferior to running a joedb server, though. Similarly, a File_Connection to a CURL_File can be used to pull from a joedb database served by a web server.

2.7.3.3. Server_Connection

Server_Connection allows connecting to a running joedb_server using the joedb network protocol.

The constructor of Server_Connection takes a Channel parameter. Two channel classes are provided:

  • Network_Channel opens a network socket to the server directly.

  • ssh::Forward_Channel connects to the server with ssh encryption and authentication.

The code below shows how to connect to a server via ssh:

#ifndef joedb_SSH_Server_Connection_declared
#define joedb_SSH_Server_Connection_declared

#include "joedb/ssh/Forward_Channel.h"
#include "joedb/concurrency/Server_Connection.h"

namespace joedb
{
 /////////////////////////////////////////////////////////////////////////////
 class SSH_Server_Connection:
 /////////////////////////////////////////////////////////////////////////////
  private ssh::Session,
  private ssh::Forward_Channel,
  public Server_Connection
 {
  public:
   SSH_Server_Connection
   (
    const char *user,
    const char *host,
    const uint16_t joedb_port,
    const unsigned ssh_port,
    const int ssh_log_level,
    std::ostream *log
   ):
    ssh::Session(user, host, ssh_port, ssh_log_level),
    ssh::Forward_Channel
    (
     *static_cast<ssh::Session *>(this),
     "localhost",
     joedb_port
    ),
    Server_Connection(*static_cast<ssh::Forward_Channel *>(this), log)
   {
   }
 };
}

#endif

2.7.4. Combining Local and Remote Concurrency

A client can handle concurrency for both its file and its connection simultaneously: it is possible for two different clients running on the same machine to share a connection to the same remote server, and also share the same local file. For this to work, the local file must be opened with Open_Mode::shared_write.