redis_migrator 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,19 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ coverage
6
+ InstalledFiles
7
+ lib/bundler/man
8
+ pkg
9
+ rdoc
10
+ spec/reports
11
+ test/tmp
12
+ test/version_tmp
13
+ tmp
14
+
15
+ # YARD artifacts
16
+ .yardoc
17
+ _yardoc
18
+ doc/
19
+ .DS_Store
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --colour -d --require spec_helper
2
+
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ Redis-migrator
2
+ ==============
3
+ Redis-migrator is a tool to redistribute keys in your redis cluster when its topography has
4
+ changed.
5
+
6
+ ##How it works
7
+
8
+ Say you are using Redis::Distributed to distribute your writes and reads across different
9
+ redis nodes. Redis::Distributed uses consitent hashing algorithm to determine where a command
10
+ will go. If you changed configuration of your cluster, for example, changed a hostname or added a new node then routes for some of the keys will change too. If you try to read such a key - you won't get a data from its old node.
11
+
12
+ Redis-migrator takes a list of nodes for your old cluster and list of nodes for your new cluster
13
+ and determines for which keys routes were changed. Then it moves those keys to new nodes.
14
+
15
+ ##Install
16
+ `gem install redis_migrator`
17
+
18
+ ##Usage
19
+ require 'redis_migrator'
20
+
21
+ # a list of redis-urls for an old cluster
22
+ old_redis_hosts = ["redis://host1.com:6379", "redis://host2.com:6379"]
23
+
24
+ # a list of redis-urls for a new cluster
25
+ old_redis_hosts = ["redis://host1.com:6379", "redis://host2.com:6379", "redis://host3.com:6379"]
26
+
27
+ migrator = Redis::Migrator.new(old_redis_hosts, new_redis_hosts)
28
+ migrator.run
29
+
30
+ ##Requirements
31
+ * ruby 1.9 or jruby (with --1.9 flag)
32
+ * redis >=2.4.14 (only on machine where migrator will be running)
33
+
34
+ ##TODO
35
+ * Error handling
@@ -0,0 +1,52 @@
1
+ class Redis
2
+ module Helper
3
+
4
+ def to_redis_proto(*cmd)
5
+ cmd.inject("*#{cmd.length}\r\n") {|acc, arg|
6
+ acc << "$#{arg.length}\r\n#{arg}\r\n"
7
+ }
8
+ end
9
+
10
+ def parse_redis_url(redis_url)
11
+ node = URI(redis_url)
12
+ path = node.path
13
+ db = path[1..-1].to_i rescue 0
14
+
15
+ {
16
+ :host => node.host,
17
+ :port => node.port || 6379,
18
+ :db => db
19
+ }
20
+ end
21
+
22
+ def copy_string(pipe, key)
23
+ value = redis.get(key)
24
+ pipe << to_redis_proto("SET", key, value)
25
+ end
26
+
27
+ def copy_hash(pipe, key)
28
+ redis.hgetall(key).each do |field, value|
29
+ pipe << to_redis_proto("HSET", key, field, value)
30
+ end
31
+ end
32
+
33
+ def copy_list(pipe, key)
34
+ redis.lrange(key, 0, -1).each do |value|
35
+ pipe << to_redis_proto("LPUSH", key, value)
36
+ end
37
+ end
38
+
39
+ def copy_set(pipe, key)
40
+ redis.smembers(key).each do |member|
41
+ pipe << to_redis_proto("SADD", key, member)
42
+ end
43
+ end
44
+
45
+ def copy_zset(pipe, key)
46
+ redis.zrange(key, 0, -1, :with_scores => true).each_slice(2) do |member, score|
47
+ pipe << to_redis_proto("ZADD", key, score, member)
48
+ end
49
+ end
50
+
51
+ end
52
+ end
@@ -0,0 +1,63 @@
1
+ require 'digest'
2
+ require_relative 'redis_helper'
3
+
4
+ class Redis
5
+ class Populator
6
+ include Redis::Helper
7
+
8
+ attr_accessor :redis
9
+
10
+ def initialize(redis_hosts)
11
+ @redis = Redis::Distributed.new(redis_hosts)
12
+ end
13
+
14
+ # Generate sets' keys to populate redis cluster
15
+ # @param num size [Integer] the number of sets that have to be created
16
+ # @return hash of keys grouped by redis node
17
+ def generate_keys(num)
18
+ num.times.inject({}) do |acc, i|
19
+ key = ::Digest::MD5.hexdigest(i.to_s)
20
+ node = redis.node_for(key).client
21
+ hash_key = "redis://#{node.host}:#{node.port}/#{node.db}"
22
+ acc[hash_key] = [] if acc[hash_key].nil?
23
+ acc[hash_key] << key
24
+ acc
25
+ end
26
+ end
27
+
28
+ # Populates sets with the given amount of members
29
+ # @param node [Hash] a parsed redis_url
30
+ # @param keys [Array] an array of sets' keys that need to be populated
31
+ def populate_keys(node, keys, size)
32
+ f = IO.popen("redis-cli -h #{node[:host]} -p #{node[:port]} -n #{node[:db]} --pipe", IO::RDWR)
33
+
34
+ keys.each do |key|
35
+ size.times.map do |x|
36
+ f << to_redis_proto("SADD", key, ::Digest::MD5.hexdigest("f" + x.to_s))
37
+ end
38
+ end
39
+
40
+ f.close
41
+ end
42
+
43
+ # Populates redis cluster
44
+ # @param keys_num [Integer] amount of redis sets that need to be populated
45
+ # @param num [Integer] number of members in each set
46
+ def populate_cluster(keys_num, num)
47
+ redis.flushdb
48
+
49
+ nodes = generate_keys(keys_num)
50
+ threads = []
51
+
52
+ nodes.keys.each do |node_url|
53
+ node = parse_redis_url(node_url)
54
+
55
+ threads << Thread.new(node, nodes[node_url], num) {|node, keys, size|
56
+ populate_keys(node, keys, size)
57
+ }
58
+ end
59
+
60
+ threads.each{|t| t.join}
61
+ end
62
+ end # class Populator
63
+ end # class Redis
@@ -0,0 +1,96 @@
1
+ require 'rubygems'
2
+ require 'redis'
3
+ require 'redis/distributed'
4
+ require_relative 'redis_migrator/redis_helper'
5
+
6
+ class Redis
7
+ class Migrator
8
+ include Redis::Helper
9
+
10
+ attr_accessor :old_cluster, :new_cluster, :old_hosts, :new_hosts
11
+
12
+ def initialize(old_hosts, new_hosts)
13
+ @old_hosts = old_hosts
14
+ @new_hosts = new_hosts
15
+ @old_cluster = Redis::Distributed.new(old_hosts)
16
+ @new_cluster = Redis::Distributed.new(new_hosts)
17
+ end
18
+
19
+ def redis
20
+ Thread.current[:redis]
21
+ end
22
+
23
+ # Finds redis keys for which migration is needed
24
+ # @return a hash of keys grouped by node they need to be written to
25
+ # @example Returned value
26
+ # { "redis://host1.com" => ['key1', 'key2', 'key3'],
27
+ # "redis://host2.com => ['key4', 'key5', 'key6']" }
28
+ def changed_keys
29
+ keys = @old_cluster.keys("*")
30
+
31
+ keys.inject({}) do |acc, key|
32
+ old_node = @old_cluster.node_for(key).client
33
+ new_node = @new_cluster.node_for(key).client
34
+
35
+ if (old_node.host != new_node.host) || (old_node.port != new_node.port)
36
+ hash_key = "redis://#{new_node.host}:#{new_node.port}/#{new_node.db}"
37
+ acc[hash_key] = [] if acc[hash_key].nil?
38
+ acc[hash_key] << key
39
+ end
40
+
41
+ acc
42
+ end
43
+ end
44
+
45
+ # Migrates a given array of keys to a given redis node
46
+ # @param node [Hash] options for redis node keys will be migrated to
47
+ # @param keys [Array] array of keys that need to be migrated
48
+ # @param options [Hash] additional options, such as :do_not_remove => true
49
+ def migrate_keys(node, keys, options={})
50
+ return false if keys.empty? || keys.nil?
51
+
52
+ Thread.current[:redis] = Redis::Distributed.new(old_hosts)
53
+
54
+ pipe = IO.popen("redis-cli -h #{node[:host]} -p #{node[:port]} -n #{node[:db]} --pipe", IO::RDWR)
55
+
56
+ keys.each {|key|
57
+ copy_key(pipe, key)
58
+
59
+ #remove key from old node
60
+ redis.node_for(key).del(key) unless options[:do_not_remove]
61
+ }
62
+
63
+ pipe.close
64
+ end
65
+
66
+ # Runs a migration process for a Redis cluster.
67
+ # @param [Hash] additional options such as :do_not_remove => true
68
+ def run(options={})
69
+ keys_to_migrate = changed_keys
70
+ puts "Migrating #{keys_to_migrate.values.flatten.count} keys"
71
+ threads = []
72
+
73
+ keys_to_migrate.keys.each do |node_url|
74
+ node = parse_redis_url(node_url)
75
+
76
+ #spawn a separate thread for each Redis pipe
77
+ threads << Thread.new(node, keys_to_migrate[node_url]) {|node, keys|
78
+ migrate_keys(node, keys, options)
79
+ }
80
+ end
81
+
82
+ threads.each{|t| t.join}
83
+ end
84
+
85
+ # Copy a given Redis key to a Redis pipe
86
+ # @param pipe [IO] a pipe opened redis-cli --pipe
87
+ # @param key [String] a Redis key that needs to be copied
88
+ def copy_key(pipe, key)
89
+ key_type = old_cluster.type(key)
90
+ return false unless ['list', 'hash', 'string', 'set', 'zset'].include?(key_type)
91
+
92
+ self.send("copy_#{key_type}", pipe, key)
93
+ end
94
+
95
+ end # class Migrator
96
+ end # class Redis
@@ -0,0 +1,22 @@
1
+ require 'redis_migrator'
2
+ require 'uri'
3
+ require 'benchmark'
4
+ require 'redis_migrator/redis_populator'
5
+
6
+ # a list of hosts for an old cluster
7
+ # You either have to start 3 Redis instances on your local - each on its
8
+ # own port: 6379, 6378, 6377. Or, better, have a real Redis nodes running
9
+ old_redis_hosts = ["redis://localhost:6379/9", "redis://localhost:6378/9"]
10
+
11
+ # a list of hosts for a new cluster
12
+ new_redis_hosts = ["redis://localhost:6379/9", "redis://localhost:6378/9", "redis://localhost:6377/9"]
13
+
14
+ r = Redis::Populator.new(old_redis_hosts)
15
+
16
+ migrator = Redis::Migrator.new(old_redis_hosts, new_redis_hosts)
17
+
18
+ Benchmark.bm do |x|
19
+ x.report("populate:") { r.populate_cluster(1000, 100) }
20
+ x.report("migrate:") { migrator.run }
21
+ end
22
+
@@ -0,0 +1,20 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("./lib", __FILE__)
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "redis_migrator"
6
+ s.version = "0.0.1"
7
+ s.date = "2012-07-05"
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Artem Yankov"]
10
+ s.email = ["artem.yankov@gmail.com"]
11
+ s.homepage = "http://rubygems.org/gems/redis_migrator"
12
+ s.summary = %q{A tool to redistribute keys in your redis cluster when its topography has changed}
13
+ s.description = %q{Redis-migrator takes a list of nodes for your old cluster and list of nodes for your new cluster and determines for which keys routes were changed. Then it moves those keys to new nodes.}
14
+
15
+ s.files = `git ls-files`.split("\n")
16
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
17
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
18
+ s.require_paths = ["lib"]
19
+ s.add_dependency('redis', '>= 2.2.2')
20
+ end
@@ -0,0 +1,63 @@
1
+ describe Redis::Migrator do
2
+
3
+ before do
4
+ Redis.should_receive(:new).any_number_of_times {|options|
5
+ MockRedis.new(options)
6
+ }
7
+
8
+ @migrator = Redis::Migrator.new(["redis://localhost:6379", "redis://localhost:6378"],
9
+ ["redis://localhost:6379", "redis://localhost:6378", "redis://localhost:6377"])
10
+
11
+ #populate old cluster with some keys
12
+ ('a'..'z').to_a.each do |key|
13
+ (1..5).to_a.each {|val| @migrator.old_cluster.sadd(key, val)}
14
+ end
15
+ end
16
+
17
+ describe Redis::Migrator, "#changed_keys" do
18
+ it "should show keys which need migration" do
19
+ @migrator.changed_keys.should == {"redis://localhost:6377/0" => ["h", "q", "s", "y", "j", "m", "n", "o"]}
20
+ end
21
+ end
22
+
23
+ describe Redis::Migrator, "#migrate_keys" do
24
+ let(:keys) { ["q", "s", "j"] }
25
+ let(:node) { {:host => "localhost", :port => 6378, :db => 1} }
26
+
27
+ before do
28
+ Redis::Distributed.should_receive(:new).and_return(@migrator.old_cluster)
29
+
30
+ @pipe = PipeMock.new(@migrator.new_cluster)
31
+ IO.should_receive(:popen).with("redis-cli -h localhost -p 6378 -n 1 --pipe", IO::RDWR).and_return(@pipe)
32
+ end
33
+
34
+ it "should copy given keys to a new cluster" do
35
+ @migrator.migrate_keys(node, keys)
36
+ (@migrator.new_cluster.keys("*") & keys).sort.should == ["j", "q", "s"]
37
+ end
38
+
39
+ it "should remove copied keys from the old redis node" do
40
+ @migrator.migrate_keys(node, keys)
41
+ (@migrator.old_cluster.keys("*") & keys).sort.should == []
42
+ end
43
+
44
+ it "should keep keys on old node if asked" do
45
+ @migrator.migrate_keys(node, keys, :do_not_remove => true)
46
+ (@migrator.old_cluster.keys("*") & keys).sort.should == ["j", "q", "s"]
47
+ end
48
+ end
49
+
50
+ describe Redis::Migrator, "#copy_key" do
51
+
52
+ it "should return FALSE for unknown key" do
53
+ @migrator.copy_key(nil, "some_key").should == false
54
+ end
55
+
56
+ it "should call copy_set if given key is set" do
57
+ @migrator.should_receive(:copy_set).with(nil, "a")
58
+ @migrator.copy_key(nil, "a")
59
+ end
60
+
61
+ end
62
+
63
+ end
@@ -0,0 +1,13 @@
1
+ class MockRedis
2
+ module Assertions
3
+ private
4
+
5
+ def assert_has_args(args, command)
6
+ unless args.any?
7
+ raise RuntimeError,
8
+ "ERR wrong number of arguments for '#{command}' command"
9
+ end
10
+ end
11
+
12
+ end
13
+ end