RDKit

RDKit is a simple toolkit to write Redis-like, single-threaded multiplexing-IO server.

The server speaks Redis RESP protocol, so you can reuse many Redis-compatible clients and tools such as:

  • redis-cli
  • redis-benchmark
  • Redic

And a lot more.

RDKit is used to power:

Code Climate Build Status

RDKit should work without problem on MRI 2.2+, may encounter bugs on earlier version of MRI or JRuby or Rubinus, in that case, please kindly open an issue on GitHub

Installation

Add this line to your application's Gemfile:

gem 'rdkit'

And then execute:

$ bundle

Or install it yourself as:

$ gem install rdkit

Usage

Generally, you should implement one subclass for each of the 3 classes: RDKit::RESPResponder, RDKit::Core and RDKit::Server, and spawn one object for each class.

Your server object should have two instance variables @responder and @core pointed to your spawned instances.

RDKit::Server

class YourServer < RDKit::Server
  def initialize
    super('0.0.0.0', 3721)

    @core = YourCore.new
    @responder = YourResponder.new(core)
  end
end

server = YourServer.new

trap(:INT) { server.stop }

server.start

This will start a TCPServer on 0.0.0.0:3721 and stops when you CTRL-C.

RDKit::RESPResponder

@responder maps Redis commands to its methods and arguments, for example info will be sent to RESPResponder#info, and info all to RESPResponder#info with "all" as its first argument.

The return ruby object of each method will be marshaled as RESP strings, for example 'OK' becomes "+OK\r\n".

For example, with following implementation in your RESPResponder subclass:

def add(a, b)
  a.to_i + b.to_i
end

You implemented an adder using RDKit! See it in action:

$ redis-cli -p 3721
127.0.0.1:3721> add 1 2
(integer) 3
127.0.0.1:3721> add 5
(error) ERR wrong number of arguments for 'add' command
127.0.0.1:3721>

The detailed algorithm can be found in resp.rb, at the time of writing it is like this:

def compose(data)
  case data
  when *%w{ OK string list set hash zset none }
    "+#{data}\r\n"
  when true
    ":1\r\n"
  when false
    ":0\r\n"
  when Integer
    ":#{data}\r\n"
  when Array
    "*#{data.size}\r\n" + data.map { |i| compose(i) }.join
  when NilClass
    # Null Bulk String, not Null Array of "*-1\r\n"
    "$-1\r\n"
  when WrongTypeError
    "-WRONGTYPE #{data.message}\r\n"
  when StandardError
    "-ERR #{data.message}\r\n"
  else
    # always Bulk String
    "$#{data.bytesize}\r\n#{data}\r\n"
  end
end

RDKit::Core

You are required to implement a tick! method. RDKit will call it periodically (currently roughly every 0.1 sec), this gives you a chance to do some house-keeping. For example:

def tick!
  save_non_critical_data! if server.cycles % 1000 == 0
end

Examples

See examples under example folder.

Implementing a counter server

A simple counter server source code listing:

require 'rdkit'

# counter/version.rb
module Counter
  VERSION = '0.0.1'
end

# counter/core.rb
module Counter
  class Core < RDKit::Core
    attr_accessor :count

    def initialize
      @count = 0
      @last_tick = Time.now
    end

    # `tick!` is called periodically by RDKit
    def tick!
      @last_tick = Time.now
    end

    def incr(n)
      @count += n
    end

    def introspection
      {
        counter_version: Counter::VERSION,
        count: @count,
        last_tick: @last_tick
      }
    end
  end
end

# counter/command_runner.rb
module Counter
  class CommandRunner < RDKit::RESPRunner
    def initialize(counter)
      @counter = counter
    end

    # every public method of this class will be accessible by clients
    def count
      @counter.count
    end

    def incr(n=1)
      @counter.incr(n.to_i)
    end
  end
end

# counter/server.rb
module Counter
  class Server < RDKit::Server
    def initialize
      super('0.0.0.0', 3721)

      # @core is required by RDKit
      @core = Core.new

      # @runner is also required by RDKit
      @runner = CommandRunner.new(@core)
    end

    def introspection
      super.merge(counter: @core.introspection)
    end
  end
end

# start server

server = Counter::Server.new

trap(:INT) { server.stop }

server.start

Connect using redis-cli

$ redis-cli -p 3721
127.0.0.1:3721> count
(integer) 0
127.0.0.1:3721> incr
(integer) 1
127.0.0.1:3721> incr 10
(integer) 11
127.0.0.1:3721> count
(integer) 11
127.0.0.1:3721> info
# Server
rdkit_version:0.0.1
multiplexing_api:select
process_id:15083
tcp_port:3721
uptime_in_seconds:268
uptime_in_days:0
hz:10

# Clients
connected_clients:1
connected_clients_peak:1

# Memory
used_memory_rss:31.89M
used_memory_peak:31.89M

# Counter
counter_version:0.0.1
count:11
last_tick:2015-05-27 20:15:38 +0800

# Stats
total_connections_received:1
total_commands_processed:6

127.0.0.1:3721> xx
(error) ERR unknown command 'xx'

Hint: if you are adventurous, try info all

Benchmarking with redis-benchmark

$ redis-benchmark -p 3721 incr
====== count ======
  10000 requests completed in 0.73 seconds
  50 parallel clients
  3 bytes payload
  keep alive: 1

0.01% <= 1 milliseconds
2.27% <= 2 milliseconds
42.31% <= 3 milliseconds
63.99% <= 4 milliseconds
96.14% <= 5 milliseconds
...
99.97% <= 68 milliseconds
99.98% <= 71 milliseconds
99.99% <= 74 milliseconds
100.00% <= 77 milliseconds
13679.89 requests per second

Since it is single-threaded, the count will be correct:

127.0.0.1:3721> count
(integer) 10000

Implementing blocked commands

Some commands will be blocking: they may either depend on external services or need some background tasks to be run.

The clients will expect those commands to be blocking calls, they will not return until the commands are finished, but we don't want the server to be blocked as well.

Therefore we introduce Server#blocking methods, execution wrapped in this method call will be run in a background thread pool, and the client will be on hold until that task is finished.

Example: see examples/blocking folder.

# blocking/command_runner.rb

module Blocking
  class CommandRunner < RDKit::RESPRunner
    attr_reader :core

    def initialize(core)
      @core = core
    end

    def block_with_callback
      core.block_with_callback

      # this is ignored, instead `on_success` block of `core.block_with_callback` is evaluated and returned
      'OK'
    end

    def block
      core.block

      'OK'
    end

    def nonblock
      core.nonblock

      'OK'
    end
  end
end

# blocking/core.rb

module Blocking
  class Core < RDKit::Core
    def block_with_callback
      on_success = lambda { 'success' }

      server.blocking(on_success) { do_something }
    end

    def block
      server.blocking { do_something }
    end

    def nonblock
      do_something
    end

    def do_something
      sleep 1
    end

    def tick!
    end
  end
end

Running:

$ redis-cli -p 3721
127.0.0.1:3721> block
OK
(1.03s)
127.0.0.1:3721> nonblock
OK
(1.01s)
127.0.0.1:3721> block_with_callback
"success"
(1.02s)

Benchmarking:

$ redis-benchmark -p 3721 -n 10 block
====== block ======
  10 requests completed in 1.03 seconds
  50 parallel clients
  3 bytes payload
  keep alive: 1

10.00% <= 1027 milliseconds
100.00% <= 1027 milliseconds
9.73 requests per second

$ redis-benchmark -p 3721 -n 10 nonblock
====== nonblock ======
  10 requests completed in 10.04 seconds
  50 parallel clients
  3 bytes payload
  keep alive: 1

10.00% <= 1001 milliseconds
20.00% <= 2005 milliseconds
30.00% <= 3010 milliseconds
40.00% <= 4013 milliseconds
50.00% <= 5018 milliseconds
60.00% <= 6022 milliseconds
70.00% <= 7027 milliseconds
80.00% <= 8030 milliseconds
90.00% <= 9034 milliseconds
100.00% <= 10039 milliseconds
1.00 requests per second

See the difference between blocking and non-blocking commands?

Implementing blocked commands

Since RDKit version 0.1.5, it allows injection of additional IO handlers into the main loop.

For examples, please refer to examples/ioinject for an injected UDP echo server.

Implemented Redis Commands

command support note
info full additional objspace and gc commands
ping full
echo full
time full
select partial/compatible redis-benchmark requires select command
config get, set, resetstat
slowlog full
client getname, setname, list, kill kill filter only supports id, addr
monitor full
debug sleep, segfault
shutdown full
get full
set without options
del full
keys without pattern (return all)
lpush full
lpop full
rpop full
llen full
lrange partial (not fully tested)
exists full
flushdb full
flushall full
mget full
mset full
strlen full
sadd full
scard full
smembers full
sismember full
srem full
hset full
hget full
hexists full
hlen full
hstrlen full
hdel full
hkeys full
hvals full
setnx full
getset full

Implemented Additional Commands

command description
gc start garbage collection immediately
heapdump ObjectSpace.dump_all to ./tmp

Development

After checking out the repo, run bin/setup to install dependencies. Then, run bin/console for an interactive prompt that will allow you to experiment.

Contributing

  1. Fork it ( https://github.com/forresty/rdkit/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request