redis-copy 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +29 -0
- data/Rakefile +9 -0
- data/bin/redis-copy +6 -0
- data/lib/redis-copy.rb +79 -0
- data/lib/redis-copy/cli.rb +90 -0
- data/lib/redis-copy/core_ext.rb +3 -0
- data/lib/redis-copy/key-emitter.rb +45 -0
- data/lib/redis-copy/strategy.rb +44 -0
- data/lib/redis-copy/strategy/classic.rb +55 -0
- data/lib/redis-copy/strategy/new.rb +32 -0
- data/lib/redis-copy/ui.rb +35 -0
- data/lib/redis-copy/ui/auto_run.rb +20 -0
- data/lib/redis-copy/ui/command_line.rb +26 -0
- data/lib/redis-copy/version.rb +5 -0
- data/redis-copy.gemspec +33 -0
- data/redis-copy_spec.rb +0 -0
- data/spec/redis-copy/key-emitter_spec.rb +33 -0
- data/spec/redis-copy/strategy_spec.rb +343 -0
- metadata +151 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Ryan Biesemeyer
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# Redis::Copy
|
2
|
+
|
3
|
+
TODO: Write a gem description
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'redis-copy'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
Or install it yourself as:
|
16
|
+
|
17
|
+
$ gem install redis-copy
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
TODO: Write usage instructions here
|
22
|
+
|
23
|
+
## Contributing
|
24
|
+
|
25
|
+
1. Fork it
|
26
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
27
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
28
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
29
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/bin/redis-copy
ADDED
data/lib/redis-copy.rb
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'redis'
|
4
|
+
require 'active_support/inflector'
|
5
|
+
require 'active_support/core_ext/string/strip' # String#strip_heredoc
|
6
|
+
|
7
|
+
require 'redis-copy/version'
|
8
|
+
require 'redis-copy/ui'
|
9
|
+
require 'redis-copy/strategy'
|
10
|
+
require 'redis-copy/key-emitter'
|
11
|
+
|
12
|
+
module RedisCopy
|
13
|
+
class << self
|
14
|
+
# @param source [String]
|
15
|
+
# @param destination [String]
|
16
|
+
# @options options [Hash<Symbol,Object>]
|
17
|
+
def copy(source, destination, options = {})
|
18
|
+
puts options.inspect
|
19
|
+
ui = UI.load(options)
|
20
|
+
|
21
|
+
source = redis_from(source)
|
22
|
+
destination = redis_from(destination)
|
23
|
+
|
24
|
+
ui.abort('source cannot equal destination!') if same_redis?(source, destination)
|
25
|
+
|
26
|
+
key_emitter = KeyEmitter.load(source, ui, options)
|
27
|
+
strategem = Strategy.load(source, destination, ui, options)
|
28
|
+
|
29
|
+
dest_empty = !(destination.randomkey) # randomkey returns string unless db empty.
|
30
|
+
|
31
|
+
return false unless ui.confirm? <<-EODESC.strip_heredoc
|
32
|
+
Source: #{source.client.id}
|
33
|
+
Destination: #{destination.client.id} (#{dest_empty ? '' : 'NOT '}empty)
|
34
|
+
Key Emitter: #{key_emitter}
|
35
|
+
Strategy: #{strategem}
|
36
|
+
EODESC
|
37
|
+
|
38
|
+
ui.abort('Destination not empty!') unless dest_empty
|
39
|
+
|
40
|
+
key_emitter.keys.each_with_object(Hash.new {0}) do |key, stats|
|
41
|
+
success = strategem.copy(key)
|
42
|
+
stats[success ? :success : :failure] += 1
|
43
|
+
stats[:attempt] += 1
|
44
|
+
|
45
|
+
unless success
|
46
|
+
ui.notify("FAIL: #{key.dump}")
|
47
|
+
ui.abort if options[:fail_fast]
|
48
|
+
end
|
49
|
+
ui.notify(stats.inspect) if (stats[:attempt] % 1000).zero?
|
50
|
+
end.tap do |stats|
|
51
|
+
ui.notify("DONE: #{stats.inspect}")
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def same_redis?(redis_a, redis_b)
|
58
|
+
# Redis::Client#id returns the connection uri
|
59
|
+
# e.g. 'redis://localhost:6379/0'
|
60
|
+
redis_a.client.id == redis_b.client.id
|
61
|
+
end
|
62
|
+
|
63
|
+
def redis_from(connection_string)
|
64
|
+
require 'uri'
|
65
|
+
connection_string = "redis://#{connection_string}" unless connection_string.start_with?("redis://")
|
66
|
+
uri = URI(connection_string)
|
67
|
+
ret = {uri: uri}
|
68
|
+
|
69
|
+
# Require the URL to have at least a host
|
70
|
+
raise ArgumentError, "invalid url: #{connection_string}" unless uri.host
|
71
|
+
|
72
|
+
host = uri.host
|
73
|
+
port = uri.port if uri.port
|
74
|
+
db = uri.path ? uri.path[1..-1].to_i : 0
|
75
|
+
|
76
|
+
Redis.new(host: host, port: port, db: db)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'redis-copy'
|
4
|
+
require 'optparse'
|
5
|
+
|
6
|
+
module RedisCopy
|
7
|
+
class CLI
|
8
|
+
REDIS_URI = (/\A(?:redis:\/\/)?([a-z0-9\-.]+)(:[0-9]{1,5})?(\/(?:(?:1[0-5])|[0-9]))?\z/i).freeze
|
9
|
+
DEFAULTS = {
|
10
|
+
ui: :command_line,
|
11
|
+
key_emitter: :default,
|
12
|
+
strategy: :auto,
|
13
|
+
fail_fast: false,
|
14
|
+
yes: false,
|
15
|
+
}.freeze unless defined?(DEFAULTS)
|
16
|
+
|
17
|
+
def initialize(argv = ARGV)
|
18
|
+
argv = argv.dup
|
19
|
+
options = {}
|
20
|
+
|
21
|
+
OptionParser.new do |opts|
|
22
|
+
opts.banner = "Usage: #{opts.program_name} [options] <source> <destination>"
|
23
|
+
opts.version = RedisCopy::VERSION
|
24
|
+
|
25
|
+
indent_desc = proc do |desc|
|
26
|
+
desc.split("\n").join("\n#{opts.summary_indent}#{' '*opts.summary_width} ")
|
27
|
+
end
|
28
|
+
|
29
|
+
opts.separator " <source> and <destination> must be redis connection uris"
|
30
|
+
opts.separator " like [redis://]<hostname>[:<port>][/<db>]"
|
31
|
+
opts.separator ''
|
32
|
+
opts.separator "Specific options:"
|
33
|
+
|
34
|
+
opts.on('--strategy STRATEGY', [:auto, :new, :classic],
|
35
|
+
indent_desc.(
|
36
|
+
"Select strategy (auto, new, classic) (default #{DEFAULTS[:strategy]})\n" +
|
37
|
+
" auto: uses new if available, otherwise fallback\n" +
|
38
|
+
" new: use redis DUMP and RESTORE commands (faster)\n" +
|
39
|
+
" classic: migrates via multiple type-specific commands"
|
40
|
+
)
|
41
|
+
) do |strategy|
|
42
|
+
options[:strategy] = strategy
|
43
|
+
end
|
44
|
+
|
45
|
+
opts.on('--[no-]dry-run', 'Output configuration and exit') do |d|
|
46
|
+
options[:dry_run] = true
|
47
|
+
end
|
48
|
+
|
49
|
+
opts.on('-d', '--[no-]debug', 'Write debug output') do |debug|
|
50
|
+
options[:debug] = debug
|
51
|
+
end
|
52
|
+
|
53
|
+
opts.on('-t', '--[no-]trace', 'Enable backtrace on failure') do |trace|
|
54
|
+
options[:trace] = trace
|
55
|
+
end
|
56
|
+
|
57
|
+
opts.on('-f', '--[no-]fail-fast', 'Abort on first failure') do |ff|
|
58
|
+
options[:fail_fast] = ff
|
59
|
+
end
|
60
|
+
|
61
|
+
opts.on('-y', '--yes', 'Automatically accept any prompts') do
|
62
|
+
options[:yes] = true
|
63
|
+
end
|
64
|
+
|
65
|
+
opts.parse!(argv)
|
66
|
+
unless argv.size == 2
|
67
|
+
opts.abort "Source and Destination must be specified\n\n" +
|
68
|
+
opts.help
|
69
|
+
end
|
70
|
+
@source = argv.shift
|
71
|
+
@destination = argv.shift
|
72
|
+
|
73
|
+
opts.abort "source is not valid URI" unless @source =~ REDIS_URI
|
74
|
+
opts.abort "destination is not valid URI" unless @destination =~ REDIS_URI
|
75
|
+
end
|
76
|
+
|
77
|
+
@config = DEFAULTS.merge(options)
|
78
|
+
end
|
79
|
+
|
80
|
+
def run!
|
81
|
+
(puts self.inspect; exit 1) if @config.delete(:dry_run)
|
82
|
+
|
83
|
+
RedisCopy::copy(@source, @destination, @config)
|
84
|
+
rescue => exception
|
85
|
+
$stderr.puts exception.message
|
86
|
+
$stderr.puts exception.backtrace if @config[:trace]
|
87
|
+
exit 1
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module RedisCopy
|
4
|
+
# A Key emitter emits keys.
|
5
|
+
# This is built to be an abstraction on top of
|
6
|
+
# redis.keys('*') (implemented by RedisCopy::KeyEmitter::Default),
|
7
|
+
# but should allow smarter implementations to be built that can handle
|
8
|
+
# billion-key dbs without blocking on IO.
|
9
|
+
module KeyEmitter
|
10
|
+
def self.load(redis, ui, options = {})
|
11
|
+
key_emitter = options.fetch(:key_emitter, :default)
|
12
|
+
const_name = key_emitter.to_s.camelize
|
13
|
+
require "redis-copy/key-emitter/#{key_emitter}" unless const_defined?(const_name)
|
14
|
+
const_get(const_name).new(redis, ui, options)
|
15
|
+
end
|
16
|
+
|
17
|
+
# @param redis [Redis]
|
18
|
+
# @param options [Hash<Symbol:String>]
|
19
|
+
def initialize(redis, ui, options = {})
|
20
|
+
@redis = redis
|
21
|
+
@ui = ui
|
22
|
+
@options = options
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Enumerable<String>]
|
26
|
+
def keys
|
27
|
+
return super if defined?(super)
|
28
|
+
raise NotImplementedError
|
29
|
+
end
|
30
|
+
|
31
|
+
def to_s
|
32
|
+
self.class.name.demodulize.humanize
|
33
|
+
end
|
34
|
+
|
35
|
+
# The default strategy blindly uses `redis.keys('*')`
|
36
|
+
class Default
|
37
|
+
include KeyEmitter
|
38
|
+
|
39
|
+
def keys
|
40
|
+
@ui.debug "REDIS: #{@redis.client.id} KEYS *"
|
41
|
+
@redis.keys('*').to_enum
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require_relative 'strategy/new'
|
4
|
+
require_relative 'strategy/classic'
|
5
|
+
|
6
|
+
module RedisCopy
|
7
|
+
module Strategy
|
8
|
+
# @param source [Redis]
|
9
|
+
# @param destination [Redis]
|
10
|
+
def self.load(source, destination, ui, options = {})
|
11
|
+
strategy = options.fetch(:strategy, :auto).to_sym
|
12
|
+
new_compatible = [source, destination].all?(&New.method(:compatible?))
|
13
|
+
copierklass = case strategy
|
14
|
+
when :classic then Classic
|
15
|
+
when :new
|
16
|
+
raise ArgumentError unless new_compatible
|
17
|
+
New
|
18
|
+
when :auto
|
19
|
+
new_compatible ? New : Classic
|
20
|
+
end
|
21
|
+
copierklass.new(source, destination, ui, options)
|
22
|
+
end
|
23
|
+
|
24
|
+
# @param source [Redis]
|
25
|
+
# @param destination [Redis]
|
26
|
+
def initialize(source, destination, ui, options = {})
|
27
|
+
@src = source
|
28
|
+
@dst = destination
|
29
|
+
@ui = ui
|
30
|
+
@opt = options.dup
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_s
|
34
|
+
self.class.name.demodulize.humanize
|
35
|
+
end
|
36
|
+
|
37
|
+
# @param key [String]
|
38
|
+
# @return [Boolean]
|
39
|
+
def copy(key)
|
40
|
+
return super if defined? super
|
41
|
+
raise NotImplementedError
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module RedisCopy
|
4
|
+
module Strategy
|
5
|
+
class Classic
|
6
|
+
include Strategy
|
7
|
+
|
8
|
+
def copy(key)
|
9
|
+
vtype = @src.type(key)
|
10
|
+
ttl = @src.ttl(key)
|
11
|
+
|
12
|
+
case vtype
|
13
|
+
when 'string'
|
14
|
+
string = @src.get(key)
|
15
|
+
@dst.set(key, string)
|
16
|
+
when "list"
|
17
|
+
list = @src.lrange(key, 0, -1)
|
18
|
+
if list.length == 0
|
19
|
+
# Empty list special case
|
20
|
+
@dst.lpush(key, '')
|
21
|
+
@dst.lpop(key)
|
22
|
+
else
|
23
|
+
list.each do |ele|
|
24
|
+
@dst.rpush(key, ele)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
when "set"
|
28
|
+
set = @src.smembers(key)
|
29
|
+
if set.length == 0
|
30
|
+
# Empty set special case
|
31
|
+
@dst.sadd(key, '')
|
32
|
+
@dst.srem(key, '')
|
33
|
+
else
|
34
|
+
set.each do |ele|
|
35
|
+
@dst.sadd(key,ele)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
when 'hash'
|
39
|
+
hash = @src.hgetall(key)
|
40
|
+
@dst.mapped_hmset(key, hash)
|
41
|
+
when 'zset'
|
42
|
+
vs_zset = @src.zrange(key, 0, -1, :with_scores => true)
|
43
|
+
sv_zset = vs_zset.map(&:reverse)
|
44
|
+
@dst.zadd(key, sv_zset)
|
45
|
+
else
|
46
|
+
return false
|
47
|
+
end
|
48
|
+
|
49
|
+
@dst.expire(key, ttl) unless ttl < 0 || vtype == 'none'
|
50
|
+
|
51
|
+
return true
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module RedisCopy
|
4
|
+
module Strategy
|
5
|
+
class New
|
6
|
+
include Strategy
|
7
|
+
|
8
|
+
def copy(key)
|
9
|
+
ttl = @src.ttl(key)
|
10
|
+
# TTL returns seconds, -1 means none set
|
11
|
+
# RESTORE ttl is in miliseconds, 0 means none set
|
12
|
+
translated_ttl = (ttl && ttl > 0) ? (ttl * 1000) : 0
|
13
|
+
|
14
|
+
dumped_value = @src.dump(key)
|
15
|
+
@dst.restore(key, translated_ttl, dumped_value)
|
16
|
+
|
17
|
+
return true
|
18
|
+
rescue Redis::CommandError => error
|
19
|
+
@ui.debug("ERROR: #{error}")
|
20
|
+
return false
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.compatible?(redis)
|
24
|
+
maj, min, *_ = redis.info['redis_version'].split('.').map(&:to_i)
|
25
|
+
return false unless maj >= 2
|
26
|
+
return false unless min >= 6
|
27
|
+
|
28
|
+
return true
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module RedisCopy
|
4
|
+
module UI
|
5
|
+
def self.load(options = {})
|
6
|
+
ui = options.fetch(:ui, :auto_run)
|
7
|
+
const_name = ui.to_s.camelize
|
8
|
+
require "redis-copy/ui/#{ui}" unless const_defined?(const_name)
|
9
|
+
const_get(const_name).new(options)
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize(options)
|
13
|
+
@options = options
|
14
|
+
end
|
15
|
+
|
16
|
+
def confirm?(prompt)
|
17
|
+
return super if defined?(super)
|
18
|
+
raise NotImplementedError
|
19
|
+
end
|
20
|
+
|
21
|
+
def abort(message = nil)
|
22
|
+
return super if defined?(super)
|
23
|
+
raise NotImplementedError
|
24
|
+
end
|
25
|
+
|
26
|
+
def notify(message)
|
27
|
+
return super if defined?(super)
|
28
|
+
raise NotImplementedError
|
29
|
+
end
|
30
|
+
|
31
|
+
def debug(message)
|
32
|
+
notify(message) if @options[:debug]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module RedisCopy
|
4
|
+
module UI
|
5
|
+
class AutoRun
|
6
|
+
include UI
|
7
|
+
|
8
|
+
def confirm?(prompt)
|
9
|
+
$stderr.puts(prompt)
|
10
|
+
true
|
11
|
+
end
|
12
|
+
def abort(message = nil)
|
13
|
+
raise RuntimeError, message
|
14
|
+
end
|
15
|
+
def notify(message)
|
16
|
+
$stderr.puts(message)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module RedisCopy
|
4
|
+
module UI
|
5
|
+
class CommandLine
|
6
|
+
include UI
|
7
|
+
|
8
|
+
def confirm?(prompt)
|
9
|
+
$stderr.puts(prompt)
|
10
|
+
return true if @options[:yes]
|
11
|
+
$stderr.puts("Continue? [yN]")
|
12
|
+
abort unless $stdin.gets.chomp =~ /y/i
|
13
|
+
true
|
14
|
+
end
|
15
|
+
|
16
|
+
def abort(message = nil)
|
17
|
+
notify(['ABORTED',message].compact.join(': '))
|
18
|
+
exit 1
|
19
|
+
end
|
20
|
+
|
21
|
+
def notify(message)
|
22
|
+
$stderr.puts(message)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
data/redis-copy.gemspec
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
lib = File.expand_path('../lib', __FILE__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require 'redis-copy/version'
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = 'redis-copy'
|
9
|
+
spec.version = RedisCopy::VERSION
|
10
|
+
|
11
|
+
authors_and_emails = [['Ryan Biesemeyer', 'ryan@yaauie.com']]
|
12
|
+
# authors_and_emails = (`sh git shortlog -sne`).lines.map do |l|
|
13
|
+
# (/(?<=\t)(.+) <(.+)>\z/).match(l.chomp).last(2)
|
14
|
+
# end.compact.map(&:to_a)
|
15
|
+
|
16
|
+
spec.authors = authors_and_emails.map(&:first)
|
17
|
+
spec.email = authors_and_emails.map(&:last)
|
18
|
+
spec.summary = 'Copy the contents of one redis db to another'
|
19
|
+
spec.homepage = 'https://github.com/yaauie/redis-copy'
|
20
|
+
spec.license = 'MIT'
|
21
|
+
|
22
|
+
spec.files = `git ls-files`.split($/)
|
23
|
+
spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) }
|
24
|
+
spec.test_files = spec.files.grep(/^(test|spec|features)\//)
|
25
|
+
spec.require_paths = ['lib']
|
26
|
+
|
27
|
+
spec.add_development_dependency 'bundler', '~> 1.3'
|
28
|
+
spec.add_development_dependency 'rake'
|
29
|
+
spec.add_development_dependency 'rspec', '~> 2.14'
|
30
|
+
|
31
|
+
spec.add_runtime_dependency 'redis'
|
32
|
+
spec.add_runtime_dependency 'activesupport'
|
33
|
+
end
|
data/redis-copy_spec.rb
ADDED
File without changes
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'redis-copy'
|
3
|
+
|
4
|
+
describe RedisCopy::KeyEmitter::Default do
|
5
|
+
let(:redis) { double }
|
6
|
+
let(:ui) { double }
|
7
|
+
let(:instance) { RedisCopy::KeyEmitter::Default.new(redis, ui)}
|
8
|
+
let(:connection_uri) { 'redis://12.34.56.78:9000/15' }
|
9
|
+
|
10
|
+
before(:each) do
|
11
|
+
redis.stub_chain('client.id').and_return(connection_uri)
|
12
|
+
ui.stub(:debug).with(anything)
|
13
|
+
end
|
14
|
+
|
15
|
+
context '#keys' do
|
16
|
+
let(:mock_return) { ['foo:bar', 'asdf:qwer'] }
|
17
|
+
before(:each) do
|
18
|
+
redis.should_receive(:keys).with('*').exactly(:once).and_return(mock_return)
|
19
|
+
end
|
20
|
+
context 'the result' do
|
21
|
+
subject { instance.keys }
|
22
|
+
its(:to_a) { should eq mock_return }
|
23
|
+
end
|
24
|
+
context 'the supplied ui' do
|
25
|
+
it 'should get a debug message' do
|
26
|
+
ui.should_receive(:debug).
|
27
|
+
with(/#{Regexp.escape(connection_uri)} KEYS \*/).
|
28
|
+
exactly(:once)
|
29
|
+
instance.keys
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,343 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
class RedisMultiplex < Struct.new(:source, :destination)
|
3
|
+
ResponseError = Class.new(RuntimeError)
|
4
|
+
|
5
|
+
def ensure_same!(&blk)
|
6
|
+
responses = {
|
7
|
+
source: capture_result(source, &blk),
|
8
|
+
destination: capture_result(destination, &blk)
|
9
|
+
}
|
10
|
+
unless responses[:source] == responses[:destination]
|
11
|
+
raise ResponseError.new(responses.to_s)
|
12
|
+
end
|
13
|
+
case responses[:destination].first
|
14
|
+
when :raised then raise responses[:destination].last
|
15
|
+
when :returned then return responses[:destination].last
|
16
|
+
end
|
17
|
+
end
|
18
|
+
alias_method :both!, :ensure_same!
|
19
|
+
|
20
|
+
def both(&blk)
|
21
|
+
both!(&blk)
|
22
|
+
true
|
23
|
+
rescue ResponseError
|
24
|
+
false
|
25
|
+
end
|
26
|
+
|
27
|
+
def capture_result(redis, &block)
|
28
|
+
return [:returned, block.call(redis)]
|
29
|
+
rescue Object => exception
|
30
|
+
return [:raised, exception]
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
shared_examples_for(RedisCopy::Strategy) do
|
35
|
+
let(:ui) { double.as_null_object }
|
36
|
+
let(:strategy) { strategy_class.new(source, destination, ui)}
|
37
|
+
let(:source) { Redis.new(db: 14) }
|
38
|
+
let(:destination) { Redis.new(db: 15) }
|
39
|
+
let(:multiplex) { RedisMultiplex.new(source, destination) }
|
40
|
+
let(:key) { rand(16**128).to_s(16) }
|
41
|
+
after(:each) { multiplex.both { |redis| redis.del(key) } }
|
42
|
+
|
43
|
+
context '#copy' do
|
44
|
+
context 'string' do
|
45
|
+
let(:source_string) { rand(16**256).to_s(16) }
|
46
|
+
before(:each) { source.set(key, source_string) }
|
47
|
+
[true,false].each do |with_expiry|
|
48
|
+
context "with_expiry(#{with_expiry})" do
|
49
|
+
before(:each) { source.expire(key, 100) } if with_expiry
|
50
|
+
context 'before' do
|
51
|
+
context 'source' do
|
52
|
+
subject { source.get(key) }
|
53
|
+
it { should_not be_nil }
|
54
|
+
it { should eq source_string }
|
55
|
+
context 'ttl' do
|
56
|
+
subject { source.ttl(key) }
|
57
|
+
it { should eq 100 } if with_expiry
|
58
|
+
it { should eq -1 } unless with_expiry
|
59
|
+
end
|
60
|
+
end
|
61
|
+
context 'destination' do
|
62
|
+
subject { destination.get(key) }
|
63
|
+
it { should be_nil }
|
64
|
+
context 'ttl' do
|
65
|
+
subject { destination.ttl(key) }
|
66
|
+
it { should eq -1 }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
context 'after' do
|
72
|
+
before(:each) { strategy.copy(key) }
|
73
|
+
context 'source' do
|
74
|
+
subject { source.get(key) }
|
75
|
+
it { should_not be_nil }
|
76
|
+
it { should eq source_string }
|
77
|
+
context 'ttl' do
|
78
|
+
subject { source.ttl(key) }
|
79
|
+
it { should eq 100 } if with_expiry
|
80
|
+
it { should eq -1 } unless with_expiry
|
81
|
+
end
|
82
|
+
end
|
83
|
+
context 'destination' do
|
84
|
+
subject { destination.get(key) }
|
85
|
+
it { should_not be_nil }
|
86
|
+
it { should eq source_string }
|
87
|
+
context 'ttl' do
|
88
|
+
subject { destination.ttl(key) }
|
89
|
+
it { should eq 100 } if with_expiry
|
90
|
+
it { should eq -1 } unless with_expiry
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
context 'list' do
|
99
|
+
let(:source_list) do
|
100
|
+
%w(foo bar baz buz bingo jango)
|
101
|
+
end
|
102
|
+
before(:each) { source_list.each{|x| source.rpush(key, x)} }
|
103
|
+
[true,false].each do |with_expiry|
|
104
|
+
context "with_expiry(#{with_expiry})" do
|
105
|
+
before(:each) { source.expire(key, 100) } if with_expiry
|
106
|
+
context 'before' do
|
107
|
+
context 'source' do
|
108
|
+
subject { source.lrange(key, 0, -1) }
|
109
|
+
it { should_not be_empty }
|
110
|
+
it { should eq source_list }
|
111
|
+
context 'ttl' do
|
112
|
+
subject { source.ttl(key) }
|
113
|
+
it { should eq 100 } if with_expiry
|
114
|
+
it { should eq -1 } unless with_expiry
|
115
|
+
end
|
116
|
+
end
|
117
|
+
context 'destination' do
|
118
|
+
subject { destination.lrange(key, 0, -1) }
|
119
|
+
it { should be_empty }
|
120
|
+
context 'ttl' do
|
121
|
+
subject { destination.ttl(key) }
|
122
|
+
it { should eq -1 }
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
context 'after' do
|
128
|
+
before(:each) { strategy.copy(key) }
|
129
|
+
context 'source' do
|
130
|
+
subject { source.lrange(key, 0, -1) }
|
131
|
+
it { should_not be_empty }
|
132
|
+
it { should eq source_list }
|
133
|
+
context 'ttl' do
|
134
|
+
subject { source.ttl(key) }
|
135
|
+
it { should eq 100 } if with_expiry
|
136
|
+
it { should eq -1 } unless with_expiry
|
137
|
+
end
|
138
|
+
end
|
139
|
+
context 'destination' do
|
140
|
+
subject { destination.lrange(key, 0, -1) }
|
141
|
+
it { should_not be_empty }
|
142
|
+
it { should eq source_list }
|
143
|
+
context 'ttl' do
|
144
|
+
subject { destination.ttl(key) }
|
145
|
+
it { should eq 100 } if with_expiry
|
146
|
+
it { should eq -1 } unless with_expiry
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
context 'set' do
|
155
|
+
let(:source_list) do
|
156
|
+
%w(foo bar baz buz bingo jango)
|
157
|
+
end
|
158
|
+
before(:each) { source_list.each{|x| source.sadd(key, x)} }
|
159
|
+
[true,false].each do |with_expiry|
|
160
|
+
context "with_expiry(#{with_expiry})" do
|
161
|
+
before(:each) { source.expire(key, 100) } if with_expiry
|
162
|
+
context 'before' do
|
163
|
+
context 'source' do
|
164
|
+
subject { source.smembers(key) }
|
165
|
+
it { should_not be_empty }
|
166
|
+
it { should =~ source_list }
|
167
|
+
context 'ttl' do
|
168
|
+
subject { source.ttl(key) }
|
169
|
+
it { should eq 100 } if with_expiry
|
170
|
+
it { should eq -1 } unless with_expiry
|
171
|
+
end
|
172
|
+
end
|
173
|
+
context 'destination' do
|
174
|
+
subject { destination.smembers(key) }
|
175
|
+
it { should be_empty }
|
176
|
+
context 'ttl' do
|
177
|
+
subject { destination.ttl(key) }
|
178
|
+
it { should eq -1 }
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
context 'after' do
|
184
|
+
before(:each) { strategy.copy(key) }
|
185
|
+
context 'source' do
|
186
|
+
subject { source.smembers(key) }
|
187
|
+
it { should_not be_empty }
|
188
|
+
it { should =~ source_list }
|
189
|
+
context 'ttl' do
|
190
|
+
subject { source.ttl(key) }
|
191
|
+
it { should eq 100 } if with_expiry
|
192
|
+
it { should eq -1 } unless with_expiry
|
193
|
+
end
|
194
|
+
end
|
195
|
+
context 'destination' do
|
196
|
+
subject { destination.smembers(key) }
|
197
|
+
it { should_not be_empty }
|
198
|
+
it { should =~ source_list }
|
199
|
+
context 'ttl' do
|
200
|
+
subject { destination.ttl(key) }
|
201
|
+
it { should eq 100 } if with_expiry
|
202
|
+
it { should eq -1 } unless with_expiry
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
context 'hash' do
|
211
|
+
let(:source_hash) do
|
212
|
+
{
|
213
|
+
'foo' => 'bar',
|
214
|
+
'baz' => 'buz'
|
215
|
+
}
|
216
|
+
end
|
217
|
+
before(:each) { source.mapped_hmset(key, source_hash) }
|
218
|
+
[true,false].each do |with_expiry|
|
219
|
+
context "with_expiry(#{with_expiry})" do
|
220
|
+
before(:each) { source.expire(key, 100) } if with_expiry
|
221
|
+
context 'before' do
|
222
|
+
context 'source' do
|
223
|
+
subject { source.hgetall(key) }
|
224
|
+
it { should_not be_empty }
|
225
|
+
it { should eq source_hash }
|
226
|
+
context 'ttl' do
|
227
|
+
subject { source.ttl(key) }
|
228
|
+
it { should eq 100 } if with_expiry
|
229
|
+
it { should eq -1 } unless with_expiry
|
230
|
+
end
|
231
|
+
end
|
232
|
+
context 'destination' do
|
233
|
+
subject { destination.hgetall(key) }
|
234
|
+
it { should be_empty }
|
235
|
+
context 'ttl' do
|
236
|
+
subject { destination.ttl(key) }
|
237
|
+
it { should eq -1 }
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
context 'after' do
|
243
|
+
before(:each) { strategy.copy(key) }
|
244
|
+
context 'source' do
|
245
|
+
subject { source.hgetall(key) }
|
246
|
+
it { should_not be_empty }
|
247
|
+
it { should eq source_hash }
|
248
|
+
context 'ttl' do
|
249
|
+
subject { source.ttl(key) }
|
250
|
+
it { should eq 100 } if with_expiry
|
251
|
+
it { should eq -1 } unless with_expiry
|
252
|
+
end
|
253
|
+
end
|
254
|
+
context 'destination' do
|
255
|
+
subject { destination.hgetall(key) }
|
256
|
+
it { should_not be_empty }
|
257
|
+
it { should eq source_hash }
|
258
|
+
context 'ttl' do
|
259
|
+
subject { destination.ttl(key) }
|
260
|
+
it { should eq 100 } if with_expiry
|
261
|
+
it { should eq -1 } unless with_expiry
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
context 'zset' do
|
270
|
+
let(:source_zset) do
|
271
|
+
{
|
272
|
+
'foo' => 1.0,
|
273
|
+
'baz' => 2.5,
|
274
|
+
'bar' => 1.1,
|
275
|
+
'buz' => 2.7
|
276
|
+
}
|
277
|
+
end
|
278
|
+
let(:vs_source_zset) { source_zset.to_a }
|
279
|
+
let(:sv_source_zset) { vs_source_zset.map(&:reverse) }
|
280
|
+
before(:each) { source.zadd(key, sv_source_zset) }
|
281
|
+
[true,false].each do |with_expiry|
|
282
|
+
context "with_expiry(#{with_expiry})" do
|
283
|
+
before(:each) { source.expire(key, 100) } if with_expiry
|
284
|
+
context 'before' do
|
285
|
+
context 'source' do
|
286
|
+
subject { source.zrange(key, 0, -1, :with_scores => true) }
|
287
|
+
it { should_not be_empty }
|
288
|
+
it { should =~ vs_source_zset }
|
289
|
+
context 'ttl' do
|
290
|
+
subject { source.ttl(key) }
|
291
|
+
it { should eq 100 } if with_expiry
|
292
|
+
it { should eq -1 } unless with_expiry
|
293
|
+
end
|
294
|
+
end
|
295
|
+
context 'destination' do
|
296
|
+
subject { destination.zrange(key, 0, -1, :with_scores => true) }
|
297
|
+
it { should be_empty }
|
298
|
+
context 'ttl' do
|
299
|
+
subject { destination.ttl(key) }
|
300
|
+
it { should eq -1 }
|
301
|
+
end
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
context 'after' do
|
306
|
+
before(:each) { strategy.copy(key) }
|
307
|
+
context 'source' do
|
308
|
+
subject { source.zrange(key, 0, -1, :with_scores => true) }
|
309
|
+
it { should_not be_empty }
|
310
|
+
it { should =~ vs_source_zset }
|
311
|
+
context 'ttl' do
|
312
|
+
subject { source.ttl(key) }
|
313
|
+
it { should eq 100 } if with_expiry
|
314
|
+
it { should eq -1 } unless with_expiry
|
315
|
+
end
|
316
|
+
end
|
317
|
+
context 'destination' do
|
318
|
+
subject { destination.zrange(key, 0, -1, :with_scores => true) }
|
319
|
+
it { should_not be_empty }
|
320
|
+
it { should =~ vs_source_zset }
|
321
|
+
context 'ttl' do
|
322
|
+
subject { destination.ttl(key) }
|
323
|
+
it { should eq 100 } if with_expiry
|
324
|
+
it { should eq -1 } unless with_expiry
|
325
|
+
end
|
326
|
+
end
|
327
|
+
end
|
328
|
+
end
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
describe RedisCopy::Strategy do
|
335
|
+
describe :New do
|
336
|
+
let(:strategy_class) { RedisCopy::Strategy::New }
|
337
|
+
it_should_behave_like RedisCopy::Strategy
|
338
|
+
end
|
339
|
+
describe :Classic do
|
340
|
+
let(:strategy_class) { RedisCopy::Strategy::Classic }
|
341
|
+
it_should_behave_like RedisCopy::Strategy
|
342
|
+
end
|
343
|
+
end
|
metadata
ADDED
@@ -0,0 +1,151 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: redis-copy
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Ryan Biesemeyer
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-10-11 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: bundler
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '1.3'
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '1.3'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: rake
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
type: :development
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: rspec
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ~>
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '2.14'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ~>
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '2.14'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: redis
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ! '>='
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
type: :runtime
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: activesupport
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ! '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
type: :runtime
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ! '>='
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
description:
|
95
|
+
email:
|
96
|
+
- ryan@yaauie.com
|
97
|
+
executables:
|
98
|
+
- redis-copy
|
99
|
+
extensions: []
|
100
|
+
extra_rdoc_files: []
|
101
|
+
files:
|
102
|
+
- .gitignore
|
103
|
+
- Gemfile
|
104
|
+
- LICENSE.txt
|
105
|
+
- README.md
|
106
|
+
- Rakefile
|
107
|
+
- bin/redis-copy
|
108
|
+
- lib/redis-copy.rb
|
109
|
+
- lib/redis-copy/cli.rb
|
110
|
+
- lib/redis-copy/core_ext.rb
|
111
|
+
- lib/redis-copy/key-emitter.rb
|
112
|
+
- lib/redis-copy/strategy.rb
|
113
|
+
- lib/redis-copy/strategy/classic.rb
|
114
|
+
- lib/redis-copy/strategy/new.rb
|
115
|
+
- lib/redis-copy/ui.rb
|
116
|
+
- lib/redis-copy/ui/auto_run.rb
|
117
|
+
- lib/redis-copy/ui/command_line.rb
|
118
|
+
- lib/redis-copy/version.rb
|
119
|
+
- redis-copy.gemspec
|
120
|
+
- redis-copy_spec.rb
|
121
|
+
- spec/redis-copy/key-emitter_spec.rb
|
122
|
+
- spec/redis-copy/strategy_spec.rb
|
123
|
+
homepage: https://github.com/yaauie/redis-copy
|
124
|
+
licenses:
|
125
|
+
- MIT
|
126
|
+
post_install_message:
|
127
|
+
rdoc_options: []
|
128
|
+
require_paths:
|
129
|
+
- lib
|
130
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
131
|
+
none: false
|
132
|
+
requirements:
|
133
|
+
- - ! '>='
|
134
|
+
- !ruby/object:Gem::Version
|
135
|
+
version: '0'
|
136
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
137
|
+
none: false
|
138
|
+
requirements:
|
139
|
+
- - ! '>='
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
142
|
+
requirements: []
|
143
|
+
rubyforge_project:
|
144
|
+
rubygems_version: 1.8.24
|
145
|
+
signing_key:
|
146
|
+
specification_version: 3
|
147
|
+
summary: Copy the contents of one redis db to another
|
148
|
+
test_files:
|
149
|
+
- spec/redis-copy/key-emitter_spec.rb
|
150
|
+
- spec/redis-copy/strategy_spec.rb
|
151
|
+
has_rdoc:
|