DEV Community

Pierre Jambet
Pierre Jambet

Posted on • Originally published at redis.pjam.me on

Rebuilding Redis in Ruby - Chapter 2 - Respond to Get and Set

Intro

In this chapter, we'll build on the foundations we established in the previous chapter. We now know how to start a TCP
server using the built-in TCPServer class. In this chapter we'll build a basic client using another built-in class,
TCPSocket.
We'll then make the server actually usable by making it respond to two commands, GET and SET.

Let's write some code

We're going to start by wrapping the code to start a server in a class, because this will make it easier to add
functionality later on.

Here's the code we'll use for now.

require 'socket'

class BasicServer

  def initialize
    server = TCPServer.new 2000
    puts "Server started at: #{ Time.now }"
    loop do
      client = server.accept
      puts "New client connected: #{ client }"
      client.puts "Hello !"
      client.puts "Time is #{ Time.now }"
      client.close
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

We can test this by saving this code in a ruby file, say server.rb, and run it with:

ruby -r "./server" -e "BasicServer.new"
Enter fullscreen mode Exit fullscreen mode

We're using this one-liner as a temporary workaround while we don't have an easy way to start the server, with an
executable, which would allow to do something like: ./simple-ruby-redis-server. The command means, run a ruby process,
first require the server.rb file located in the same folder, and then execute the following command, BasicServer.new.
We should see a line indicating that the server started, with the current time.

Let's confirm that the server is running as expected by running, in a different shell: nc localhost 2000, the output
should be similar to the following, with a different date:

Hello !
Time is 2020-04-18 10:54:10 -0400
Enter fullscreen mode Exit fullscreen mode

You should also see a line in the shell where you started your server, indicating that a client successfully connected:

New client connected: #<TCPSocket:0x00007fd83108f9d8>
Enter fullscreen mode Exit fullscreen mode

The string after TCPSocket:0x will very likely be different on your machine, ruby's default
to_s method uses the object id, which is pretty much
always gonna be different.

Reading from the socket in the client

So, now that we confirmed that our BasicServer class runs correctly, let's connect to it in ruby instead of using
nc. The direct parent class of TCPServer is TCPSocket, and according to the documentation:

TCPSocket represents a TCP/IP client socket.

So far we've been using the code examples provided in the documentation, we can still do that here, we can paste the
lines one by one:

irb(main):001:0> require 'socket'
=> true
Enter fullscreen mode Exit fullscreen mode

We already know what this line does, and we can confirm that there is a file,
tcpsocket.c
, in the ruby/ext/socket/ folder,
that defines TCPSocket. Moving on.

irb(main):002:0> s = TCPSocket.new 'localhost', 2000
Enter fullscreen mode Exit fullscreen mode

This line creates a new socket for the given host and port. It requires that a socket is listening on the other side,
which you can confirm by either running this before starting your server, by killing your server and re-running this, or
by changing the port value to a port that is unused, like 2001. You should see a Connection refused error:
Errno::ECONNREFUSED (Connection refused - connect(2) for "localhost" port 2000). connect(2) in the previous error
message refers to the connect system call. The number 2 refers to the section of the manual, which is an optional
argument to the man command. It turns out there is no other man page for connect, so you can run man connect to
learn more about it, or you can be explicit and ask for a specific section, with man 2 connect. This is useful for
other pages, such as accept, man acccept returns the page for accept(8), but there is also an accept system
call, which you can read the documentation for with man 2 accept.

As we start adding more features, we'll see more "system calls", often called syscalls, to quote Wikipedia:

In computing, a system call (commonly abbreviated to syscall) is the programmatic way in which a computer program
requests a service from the kernel of the operating system on which it is executed.

So far we've implictly seen two syscalls

  • accept: This is how you connect to existing socket
  • socket: This is how we created the server in the previous chapter

There are many syscalls, this is list on linux, there are similarities but the list is different on macOS.

Let's now look at the while loop from the documentation of TCPSocket:

while (line = s.gets) != nil do puts line end # This is different from the doc, but adapted to a one-liner
Enter fullscreen mode Exit fullscreen mode

After running it in irb, the output should be:

irb(main):003:0> while (line = s.gets) != nil do puts line end
Hello !
Time is 2020-04-18 20:34:34 -0400
=> nil
Enter fullscreen mode Exit fullscreen mode

We used a new method, gets, its documentation states:

Reads the next "line" from the I/O stream; lines are separated by sep. A separator of nil reads the entire contents,
and a zero-length separator reads the input a paragraph at a time (two successive newlines in the input separate
paragraphs). The stream must be opened for reading or an IOError will be raised. The line read in will be returned and
also assigned to $_. Returns nil if called at end of file. If the first argument is an integer, or optional second
argument is given, the returning string would not be longer than the given value in bytes.

As we can see, we were able to connect to the server, on localhost, over port 2000, and we were able to read what the
server wrote with the gets method, one line at a time.

In true Ruby fashion, there are a few different ways to read from the socket, the example we just looked at used gets,
which is defined on IO, but if you look at the IO documentation, you'll find a few other similar methods, read,
read_nonblock, readline & readlines to name a few. Exploring the differences between these methods is left as an
excercise to the reader.

Ok, I have to admit, I always wanted to write that. I hate when books/posts do that. But seriously, it's a little bit
off topic for now, so we'll get back to it later, read can be convenient because it does not require a max length
argument as some of the others methods do and defaults to reading the whole thing, aka until it reaches EOF, as
opposed to doing it line by line like gets and readline do. As we'll see later on, it's also quite convenient
sometimes to read a whole line received through a socket, whereas read will keep on reading until it sees EOF, which
basically means, until the stream is closed.

There are at least two other methods, which map closely to system calls, recvfrom on IPSocket and recv on
BasicSocket. So gets it is for now.

Let's close this irb session and kill the server with Ctrl-C for now, and create a new file, client.rb with the
following content:

require 'socket'

socket = TCPSocket.new 'localhost', 2000
message = ""
message << new_line while new_line = socket.gets
puts message
Enter fullscreen mode Exit fullscreen mode

And with this last step, we now have a way to exercise what we went through without going through irb, first we can
start the server with the following command: ruby -r "./server" -e "BasicServer.new"

The -r option from ruby, according to ruby -h, is used to "require the library before executing your script". So by
passing a relative path, we require the content of the server.rb file. The -e option is used to "one line of
script. Several -e's allowed. [...]". Omitting the -r option would fail with a NameError because ruby wouldn't be
able to find a definition for BasicServer.

In another shell, run ruby client.rb and you will see an output similar to what we saw earlier, Hello !, followed by
a string containing the time when the server received the connection.

Now that we can connect to a server, let's see how to send data. This is a necessary step since we want clients to send
GET and SET requests, and have the server respond accordingly.

Sending data from the client

Let's assume that our server is still running, we can start by sending a string with nc with the following command:
echo -n "Hello Server, this is Client" | nc localhost 2000. The output is still the same, minus the time
difference. So let's make some changes to the server to print what we received from the client.

As a reminder, our server is an instance of TCPServer, which happens to be a subclass of TCPSocket. This is great
news, because that means that we can read what the client sent the same way we read what the server sent to the
client. This is also an indication that at the end of the day, the client and the server have a lot in common. A socket
is open on each side, one of the main differences is that the TCPServer class adds the accept and listen methods,
which we can't do with a TCPSocket.

Let's add a print statement to the server code:

loop do
  client = server.accept
  puts "New client connected: #{ client }"
  client_message = client.read
  if client_message
    puts "Message received from #{ client }: #{ client_message }"
  end
  client.puts "Hello !"
  client.puts "Time is #{ Time.now }"
  client.close
end
Enter fullscreen mode Exit fullscreen mode

Let's close our server if it was still running, with Ctrl-C, and restart it. And re-run the previous nc command. The
output for the client is the same, we didn't change anything there, but our server logs now show a new line:

Server started at: 2020-04-26 20:52:13 -0400
New client connected: #<TCPSocket:0x00007fb879931f78>
Message received from #<TCPSocket:0x00007fb879931f78>: Hello Server, this is Client # This is new!
Enter fullscreen mode Exit fullscreen mode

Perfect! So it looks like we have all the pieces we need for now:

  • We can create a client that can connect to our server
  • The client can send data to the server
  • The server can read the data sent from the client and write data back
  • The client can read the data it received from the server

Wrapping up

We want our server to understand two commands, GET and SET, for the sake of simplicity, let's focus on their most
basic versions.

GET takes one argument, the key name, and return its value if it exists and (nil) otherwise. Note that this is
purposefully different from the Redis Protocol. We'll focus on
actual redis compatibility later on, once we have a stronger foundation for our implementation.

SET takes two arguments, the key value and the key name. It sets the value accordingly, erasing the previous value if
there was one.

All other command will return the following string (error) ERR unknown command `foo`, with args beginning with:
<args>
, where <args> is everything that follows the command, aka, everything after the first space.

We are not performing any other validations for now, again, to focus on having a basic implementation. We'll make it
more robust later on.

require 'socket'

class BasicServer

  COMMANDS = [
    "GET",
    "SET",
  ]

  def initialize
    @data_store = {}

    server = TCPServer.new 2000
    puts "Server started at: #{ Time.now }"
    loop do
      client = server.accept
      puts "New client connected: #{ client }"
      client_command_with_args = client.gets
      if client_command_with_args && client_command_with_args.length > 0
        response = handle_client_command(client_command_with_args)
        client.puts response
      else
        puts "Empty request received from #{ client }"
      end
      client.close
    end
  end

  private

  def handle_client_command(client_command_with_args)
    command_parts = client_command_with_args.split
    command = command_parts[0]
    args = command_parts[1..-1]
    if COMMANDS.include?(command)
      if command == "GET"
        if args.length != 1
          "(error) ERR wrong number of arguments for '#{ command }' command"
        else
          @data_store.fetch(args[0], "(nil)")
        end
      elsif command == "SET"
        if args.length != 2
          "(error) ERR wrong number of arguments for '#{ command }' command"
        else
          @data_store[args[0]] = args[1]
          'OK'
        end
      end
    else
      formatted_args = args.map { |arg| "`#{ arg }`," }.join(" ")
      "(error) ERR unknown command `#{ command }`, with args beginning with: #{ formatted_args }"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The key element of this implementation is that we initialize an empty Hash when creating a new instance of
BasicServer, and this is what we use to store the data when responding to SET requests. Using a Hash here could
almost be considered "cheating". If you have already looked at the Redis source code, a big part of its implementation
is related to how data is stored, and since it's written in C, it can't "just" say: "throw this value, for this key, in
this existing data structure and call it a day". That being said, as already mentioned multiple times, we're taking an
incremental approach. For now we're focusing on how to integrate the networking parts of our client/server architecture,
how to exchange information, and while we're busy with this, Ruby's built-in Hash class is a amazing, it supports,
among many other, the two main features we need: [] &
[]=.

Redis uses the SipHash algorithm, as we can see in the siphash.c file, and
as it turns out, this seems to be what Ruby uses as well! I say seem here because by browsing
the code I couldn't really confirm that siphash was used in hash.c.

And here is the client code:

require 'socket'

class BasicClient

  COMMANDS = [
    "GET",
    "SET",
  ]

  def get(key)
    socket = TCPSocket.new 'localhost', 2000
    result = nil
    socket.puts "GET #{ key }"
    result = socket.gets
    socket.close
    result
  end

  def set(key, value)
    socket = TCPSocket.new 'localhost', 2000
    result = nil
    socket.puts "SET #{ key } #{ value }"
    result = socket.gets
    socket.close
    result
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's confirm that it works as expected, as usual, let's start the server in one shell and run commands from another
one with irb -r "./client"

irb(main):001:0> client = BasicClient.new
irb(main):002:0> client.get 1
=> "(nil)\n"
irb(main):003:0> client.get 2
=> "(nil)\n"
irb(main):004:0> client.set 1, 2
=> "2\n"
irb(main):005:0> client.set 2, 3
=> "3\n"
irb(main):006:0> client.get 1
=> "2\n"
irb(main):007:0> client.get 2
=> "3\n"
Enter fullscreen mode Exit fullscreen mode

A few tests

I apologize if you're a TDD enthusiast because what I'm about to do might make you cringe. Now that we have added these
features and manually tested that they worked, we're going to add a few tests.

I'm using minitest, because I like how easy it is to setup. Yes, I know, I am technically already breaking the rules I
laid out in the "from scratch" section of the first chapter, but we're talking about tests here. While it might be
interesting to write a testing library from scratch, and I might do that at some point, I think it's ok to leverage a
library and focus on the main task, building a Redis server.

And while minitest is a gem, it has been included by default in ruby for a while now (at least since
2.0), so I'm not really breaking the rules!

require 'minitest/autorun'
require 'timeout'
require 'stringio'
require './server'

describe 'BasicServer' do

  def connect_to_server
    socket = nil
    # The server might not be ready to listen to accepting connections by the time we try to connect from the main
    # thread, in the parent process. Using timeout here guarantees that we won't wait more than 1s, which should
    # more than enough time for the server to start, and the retry loop inside, will retry to connect every 10ms
    # until it succeeds
    Timeout::timeout(1) do
      loop do
        begin
          socket = TCPSocket.new 'localhost', 2000
          break
        rescue
          sleep 0.01
        end
      end
    end
    socket
  end

  def with_server

    child = Process.fork do
      # We're effectively silencing the server with these two lines
      # stderr would have logged something when it receives SIGINT, with a complete stacktrace
      $stderr = StringIO.new
      # stdout would haev logged the "Server started ..." & "New client connected ..." lines
      $stdout = StringIO.new
      BasicServer.new
    end

    yield

  ensure
    if child
      Process.kill('INT', child)
      Process.wait(child)
    end
  end

  def assert_command_results(command_result_pairs)
    with_server do
      command_result_pairs.each do |command, expected_result|
        begin
          socket = connect_to_server
          socket.puts command
          response = socket.gets
          assert_equal response, expected_result + "\n"
        ensure
          socket.close if socket
        end
      end
    end
  end

  describe 'when initialized' do
    it 'listens on port 2000' do
      with_server do
        # lsof stands for "list open files", see for more info https://stackoverflow.com/a/4421674
        lsof_result = `lsof -nP -i4TCP:2000 | grep LISTEN`
        assert_match "ruby", lsof_result
      end
    end
  end

  describe 'GET' do
    it 'handles unexpected number of arguments' do
      assert_command_results [
        [ 'GET', '(error) ERR wrong number of arguments for \'GET\' command' ],
      ]
    end

    it 'returns (nil) for unknown keys' do
      assert_command_results [
        [ 'GET 1', '(nil)' ],
      ]
    end

    it 'returns the value previously set by SET' do
      assert_command_results [
        [ 'SET 1 2', 'OK' ],
        [ 'GET 1', '2']
      ]
    end
  end

  describe 'SET' do
    it 'handles unexpected number of arguments' do
      assert_command_results [
        [ 'SET', '(error) ERR wrong number of arguments for \'SET\' command' ],
      ]
    end

    it 'returns OK' do
      assert_command_results [
        [ 'SET 1 3', 'OK' ],
      ]
    end

  end

  describe 'Unknown commands' do
    it 'returns an error message' do
      assert_command_results [
        [ 'NOT A COMMAND', '(error) ERR unknown command `NOT`, with args beginning with: `A`, `COMMAND`,' ],
      ]
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

There are a few oddities in there, most of them are documented inline, but the main approach is that for each test, we
create a new process with Process.fork, and we start the server in the new process. We then connect to it from the
original process, and send commands over the TCP connection.

Conclusion

Well, that was a lot, but we now have a nice base to build from.

There are many things we can do from now, but one that I think is important is to start thinking about how it would
handle multiple clients. There are a few major flaws with the current implementation, one of them being that once a
client connects, it will block until it receives a new line from the client that just connected, or until the client
disconnects.

We can reproduce this behavior by opening a third shell, requiring socket and creating a new socket connected to
localhost over port 2000. Now go back to the irb console we used previously to test our client and try to call get
or set. It will hang. This is because the server is running a single thread, and that thread is waiting for
client.gets to return something. We have two ways to make gets return, either send a new line with puts from the
newly created socket, or close it, with close.

The BasicClient works around this issue by never keeping a connection open, when you call get or set, it starts
from scratch, establishes a new connection, sends the command, reads the response, and closes the socket. Effectively
killing the connection. This works for now, but is fairly wasteful, TCP connections can stay open and be reused. Let's
illustrate this right now, with a non scientific quick benchmark:

We'll start by adding a logging statement to the get method in the client class, to observe how long it takes to
connect to the server, send a command, and get a response:

def get(key)
  t0 = Time.now
  # ...
  puts "Time elapsed: #{ (Time.now - t0)*1000 }ms"
  result
end
Enter fullscreen mode Exit fullscreen mode

Results vary a lot from one run to another on my machine, but I'm getting in the 1ms to 1.8ms range, ish. Remember that
both the client and server are running on the same machine, we would see very different numbers if these were running on
different machines.

Note: This is something I am really interested in and might take a few minutes to spin up two EC2
instances and see what the numbers are on AWS.

Let's now see what it looks like to run two GET commands, sequentially:

def two_full_gets(key)
  t0 = Time.now
  get(key)
  get(key)
  puts "Time elapsed: #{ (Time.now - t0)*1000 }ms"
end
Enter fullscreen mode Exit fullscreen mode

Interestingly I am consistently seeing the second get call to be 40% to 100% faster than the first one. After running
it a dozen of times, I am seeing results in the 1.6ms to 4ms range.

Let's end this quick test by running two gets, over the same connection!

def two_gets_a_single_connection(key)
  t0 = Time.now
  socket = TCPSocket.new 'localhost', 2000
  result = nil
  socket.puts "GET #{ key }"
  socket.puts "GET #{ key }"
  result = socket.gets
  socket.close
  puts "Time elapsed: #{ (Time.now - t0)*1000 }ms"
  result
end
Enter fullscreen mode Exit fullscreen mode

Again, I ran this about a dozen times, and saw it as low as .982ms and never above 1.4ms. This makes sense, establishing
a connection is not free. When we create an instance of TCPSocket, ruby delegates to the OS through the socket
syscall and it attempts to establish a network connection. All of that work is done twice in the first example, and only
once in the second example. And once again, it is fair to assume that the difference would be even bigger with two
different hosts, because to establish a connection, TCP packets would have to actually travel from one host to the
other, over the network, instead of on the same physical machine.

The next chapter will look at what our options are to make sure that our server can keep client connections open and
still serve all its clients efficiently, without blocking like the current implementation does.

Top comments (0)