cs-em-hiredis 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +6 -0
- data/.rspec +1 -0
- data/Gemfile +3 -0
- data/LICENCE +19 -0
- data/README.md +85 -0
- data/Rakefile +11 -0
- data/cs-em-hiredis.gemspec +25 -0
- data/examples/pubsub.rb +52 -0
- data/lib/em-hiredis.rb +49 -0
- data/lib/em-hiredis/base_client.rb +199 -0
- data/lib/em-hiredis/client.rb +51 -0
- data/lib/em-hiredis/connection.rb +70 -0
- data/lib/em-hiredis/event_emitter.rb +29 -0
- data/lib/em-hiredis/lock.rb +89 -0
- data/lib/em-hiredis/pubsub_client.rb +187 -0
- data/lib/em-hiredis/version.rb +5 -0
- data/spec/base_client_spec.rb +115 -0
- data/spec/connection_spec.rb +56 -0
- data/spec/live_redis_protocol_spec.rb +527 -0
- data/spec/pubsub_spec.rb +314 -0
- data/spec/redis_commands_spec.rb +910 -0
- data/spec/spec_helper.rb +17 -0
- data/spec/support/connection_helper.rb +11 -0
- data/spec/support/redis_mock.rb +65 -0
- data/spec/url_param_spec.rb +43 -0
- metadata +125 -0
@@ -0,0 +1,51 @@
|
|
1
|
+
module EventMachine::Hiredis
|
2
|
+
class Client < BaseClient
|
3
|
+
def self.connect(host = 'localhost', port = 6379)
|
4
|
+
new(host, port).connect
|
5
|
+
end
|
6
|
+
|
7
|
+
def monitor(&blk)
|
8
|
+
@monitoring = true
|
9
|
+
method_missing(:monitor, &blk)
|
10
|
+
end
|
11
|
+
|
12
|
+
def info
|
13
|
+
df = method_missing(:info)
|
14
|
+
df.callback { |response|
|
15
|
+
info = {}
|
16
|
+
response.each_line do |line|
|
17
|
+
key, value = line.split(":", 2)
|
18
|
+
info[key.to_sym] = value.chomp
|
19
|
+
end
|
20
|
+
df.succeed(info)
|
21
|
+
}
|
22
|
+
df.callback { |info| yield info } if block_given?
|
23
|
+
df
|
24
|
+
end
|
25
|
+
|
26
|
+
# Gives access to a richer interface for pubsub subscriptions on a
|
27
|
+
# separate redis connection
|
28
|
+
#
|
29
|
+
def pubsub
|
30
|
+
@pubsub ||= begin
|
31
|
+
PubsubClient.new(@host, @port, @password, @db).connect
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def subscribe(*channels)
|
36
|
+
raise "Use pubsub client"
|
37
|
+
end
|
38
|
+
|
39
|
+
def unsubscribe(*channels)
|
40
|
+
raise "Use pubsub client"
|
41
|
+
end
|
42
|
+
|
43
|
+
def psubscribe(channel)
|
44
|
+
raise "Use pubsub client"
|
45
|
+
end
|
46
|
+
|
47
|
+
def punsubscribe(channel)
|
48
|
+
raise "Use pubsub client"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'hiredis/reader'
|
2
|
+
|
3
|
+
module EventMachine::Hiredis
|
4
|
+
class Connection < EM::Connection
|
5
|
+
include EventMachine::Hiredis::EventEmitter
|
6
|
+
|
7
|
+
def initialize(host, port)
|
8
|
+
super
|
9
|
+
@host, @port = host, port
|
10
|
+
end
|
11
|
+
|
12
|
+
def reconnect(host, port)
|
13
|
+
super
|
14
|
+
@host, @port = host, port
|
15
|
+
end
|
16
|
+
|
17
|
+
def connection_completed
|
18
|
+
EM::Hiredis.logger.debug("#{to_s}: connection open")
|
19
|
+
@reader = ::Hiredis::Reader.new
|
20
|
+
emit(:connected)
|
21
|
+
end
|
22
|
+
|
23
|
+
def receive_data(data)
|
24
|
+
@reader.feed(data)
|
25
|
+
until (reply = @reader.gets) == false
|
26
|
+
emit(:message, reply)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def unbind
|
31
|
+
EM::Hiredis.logger.debug("#{to_s}: connection unbound")
|
32
|
+
emit(:closed)
|
33
|
+
end
|
34
|
+
|
35
|
+
def send_command(sym, *args)
|
36
|
+
send_data(command(sym, *args))
|
37
|
+
end
|
38
|
+
|
39
|
+
def to_s
|
40
|
+
"Redis connection #{@host}:#{@port}"
|
41
|
+
end
|
42
|
+
|
43
|
+
protected
|
44
|
+
|
45
|
+
COMMAND_DELIMITER = "\r\n"
|
46
|
+
|
47
|
+
def command(*args)
|
48
|
+
command = []
|
49
|
+
command << "*#{args.size}"
|
50
|
+
|
51
|
+
args.each do |arg|
|
52
|
+
arg = arg.to_s
|
53
|
+
command << "$#{string_size arg}"
|
54
|
+
command << arg
|
55
|
+
end
|
56
|
+
|
57
|
+
command.join(COMMAND_DELIMITER) + COMMAND_DELIMITER
|
58
|
+
end
|
59
|
+
|
60
|
+
if "".respond_to?(:bytesize)
|
61
|
+
def string_size(string)
|
62
|
+
string.to_s.bytesize
|
63
|
+
end
|
64
|
+
else
|
65
|
+
def string_size(string)
|
66
|
+
string.to_s.size
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module EventMachine::Hiredis
|
2
|
+
module EventEmitter
|
3
|
+
def on(event, &listener)
|
4
|
+
_listeners[event] << listener
|
5
|
+
end
|
6
|
+
|
7
|
+
def emit(event, *args)
|
8
|
+
_listeners[event].each { |l| l.call(*args) }
|
9
|
+
end
|
10
|
+
|
11
|
+
def remove_listener(event, &listener)
|
12
|
+
_listeners[event].delete(listener)
|
13
|
+
end
|
14
|
+
|
15
|
+
def remove_all_listeners(event)
|
16
|
+
_listeners.delete(event)
|
17
|
+
end
|
18
|
+
|
19
|
+
def listeners(event)
|
20
|
+
_listeners[event]
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def _listeners
|
26
|
+
@_listeners ||= Hash.new { |h,k| h[k] = [] }
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
module EM::Hiredis
|
2
|
+
# Distributed lock built on redis
|
3
|
+
class Lock
|
4
|
+
def onexpire(&blk); @onexpire = blk; end
|
5
|
+
|
6
|
+
def initialize(redis, key, timeout)
|
7
|
+
@redis, @key, @timeout = redis, key, timeout
|
8
|
+
@locked = false
|
9
|
+
@expiry = nil
|
10
|
+
end
|
11
|
+
|
12
|
+
# Acquire the lock
|
13
|
+
#
|
14
|
+
# It is ok to call acquire again before the lock expires, which will attempt to extend the existing lock.
|
15
|
+
#
|
16
|
+
# Returns a deferrable which either succeeds if the lock can be acquired, or fails if it cannot. In both cases the expiry timestamp is returned (for the new lock or for the expired one respectively)
|
17
|
+
def acquire
|
18
|
+
df = EM::DefaultDeferrable.new
|
19
|
+
expiry = new_expiry
|
20
|
+
@redis.setnx(@key, expiry) { |setnx|
|
21
|
+
if setnx == 1
|
22
|
+
lock_acquired(expiry)
|
23
|
+
df.succeed(expiry)
|
24
|
+
else
|
25
|
+
attempt_to_acquire_existing_lock(df)
|
26
|
+
end
|
27
|
+
}
|
28
|
+
return df
|
29
|
+
end
|
30
|
+
|
31
|
+
# Release the lock
|
32
|
+
def unlock
|
33
|
+
EM.cancel_timer(@expire_timer) if @expire_timer
|
34
|
+
|
35
|
+
if @locked && Time.now.to_i < @expiry
|
36
|
+
EM::Hiredis.logger.debug "Lock: released #{@key}"
|
37
|
+
@redis.del(@key)
|
38
|
+
else
|
39
|
+
EM::Hiredis.logger.debug "Lock: could not release #{@key}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# This should not be used in normal operation - force clear
|
44
|
+
def clear
|
45
|
+
@redis.del(@key)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def attempt_to_acquire_existing_lock(df)
|
51
|
+
@redis.get(@key) { |expiry_1|
|
52
|
+
expiry_1 = expiry_1.to_i
|
53
|
+
if expiry_1 == @expiry || expiry_1 < Time.now.to_i
|
54
|
+
# Either the lock was ours or the lock has already expired
|
55
|
+
expiry = new_expiry
|
56
|
+
@redis.getset(@key, expiry) { |expiry_2|
|
57
|
+
expiry_2 = expiry_2.to_i
|
58
|
+
if expiry_2 == @expiry || expiry_2 < Time.now.to_i
|
59
|
+
lock_acquired(expiry)
|
60
|
+
df.succeed(expiry)
|
61
|
+
else
|
62
|
+
# Another client got there first
|
63
|
+
EM::Hiredis.logger.debug "Lock: failed to acquire #{@key}"
|
64
|
+
df.fail(expiry_2)
|
65
|
+
end
|
66
|
+
}
|
67
|
+
else
|
68
|
+
EM::Hiredis.logger.debug "Lock: failed to acquire #{@key}"
|
69
|
+
df.fail(expiry_1)
|
70
|
+
end
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
def new_expiry
|
75
|
+
Time.now.to_i + @timeout + 1
|
76
|
+
end
|
77
|
+
|
78
|
+
def lock_acquired(expiry)
|
79
|
+
EM::Hiredis.logger.debug "Lock: acquired #{@key}"
|
80
|
+
@locked = true
|
81
|
+
@expiry = expiry
|
82
|
+
EM.cancel_timer(@expire_timer) if @expire_timer
|
83
|
+
@expire_timer = EM.add_timer(@timeout) {
|
84
|
+
EM::Hiredis.logger.debug "Lock: #{@key} will expire in 1s"
|
85
|
+
@onexpire.call if @onexpire
|
86
|
+
}
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,187 @@
|
|
1
|
+
module EventMachine::Hiredis
|
2
|
+
class PubsubClient < BaseClient
|
3
|
+
PUBSUB_MESSAGES = %w{message pmessage subscribe unsubscribe psubscribe punsubscribe}.freeze
|
4
|
+
|
5
|
+
def initialize(host='localhost', port='6379', password=nil, db=nil)
|
6
|
+
@subs, @psubs = [], []
|
7
|
+
@pubsub_defs = Hash.new { |h,k| h[k] = [] }
|
8
|
+
super
|
9
|
+
end
|
10
|
+
|
11
|
+
def connect
|
12
|
+
@sub_callbacks = Hash.new { |h, k| h[k] = [] }
|
13
|
+
@psub_callbacks = Hash.new { |h, k| h[k] = [] }
|
14
|
+
|
15
|
+
# Resubsubscribe to channels on reconnect
|
16
|
+
on(:reconnected) {
|
17
|
+
raw_send_command(:subscribe, *@subs) if @subs.any?
|
18
|
+
raw_send_command(:psubscribe, *@psubs) if @psubs.any?
|
19
|
+
}
|
20
|
+
|
21
|
+
super
|
22
|
+
end
|
23
|
+
|
24
|
+
# Subscribe to a pubsub channel
|
25
|
+
#
|
26
|
+
# If an optional proc / block is provided then it will be called when a
|
27
|
+
# message is received on this channel
|
28
|
+
#
|
29
|
+
# @return [Deferrable] Redis subscribe call
|
30
|
+
#
|
31
|
+
def subscribe(channel, proc = nil, &block)
|
32
|
+
if cb = proc || block
|
33
|
+
@sub_callbacks[channel] << cb
|
34
|
+
end
|
35
|
+
@subs << channel
|
36
|
+
raw_send_command(:subscribe, channel)
|
37
|
+
return pubsub_deferrable(channel)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Unsubscribe all callbacks for a given channel
|
41
|
+
#
|
42
|
+
# @return [Deferrable] Redis unsubscribe call
|
43
|
+
#
|
44
|
+
def unsubscribe(channel)
|
45
|
+
@sub_callbacks.delete(channel)
|
46
|
+
@subs.delete(channel)
|
47
|
+
raw_send_command(:unsubscribe, channel)
|
48
|
+
return pubsub_deferrable(channel)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Unsubscribe a given callback from a channel. Will unsubscribe from redis
|
52
|
+
# if there are no remaining subscriptions on this channel
|
53
|
+
#
|
54
|
+
# @return [Deferrable] Succeeds when the unsubscribe has completed or
|
55
|
+
# fails if callback could not be found. Note that success may happen
|
56
|
+
# immediately in the case that there are other callbacks for the same
|
57
|
+
# channel (and therefore no unsubscription from redis is necessary)
|
58
|
+
#
|
59
|
+
def unsubscribe_proc(channel, proc)
|
60
|
+
df = EM::DefaultDeferrable.new
|
61
|
+
if @sub_callbacks[channel].delete(proc)
|
62
|
+
if @sub_callbacks[channel].any?
|
63
|
+
# Succeed deferrable immediately - no need to unsubscribe
|
64
|
+
df.succeed
|
65
|
+
else
|
66
|
+
unsubscribe(channel).callback { |_|
|
67
|
+
df.succeed
|
68
|
+
}
|
69
|
+
end
|
70
|
+
else
|
71
|
+
df.fail
|
72
|
+
end
|
73
|
+
return df
|
74
|
+
end
|
75
|
+
|
76
|
+
# Pattern subscribe to a pubsub channel
|
77
|
+
#
|
78
|
+
# If an optional proc / block is provided then it will be called (with the
|
79
|
+
# channel name and message) when a message is received on a matching
|
80
|
+
# channel
|
81
|
+
#
|
82
|
+
# @return [Deferrable] Redis psubscribe call
|
83
|
+
#
|
84
|
+
def psubscribe(pattern, proc = nil, &block)
|
85
|
+
if cb = proc || block
|
86
|
+
@psub_callbacks[pattern] << cb
|
87
|
+
end
|
88
|
+
@psubs << pattern
|
89
|
+
raw_send_command(:psubscribe, pattern)
|
90
|
+
return pubsub_deferrable(pattern)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Pattern unsubscribe all callbacks for a given pattern
|
94
|
+
#
|
95
|
+
# @return [Deferrable] Redis punsubscribe call
|
96
|
+
#
|
97
|
+
def punsubscribe(pattern)
|
98
|
+
@psub_callbacks.delete(pattern)
|
99
|
+
@psubs.delete(pattern)
|
100
|
+
raw_send_command(:punsubscribe, pattern)
|
101
|
+
return pubsub_deferrable(pattern)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Unsubscribe a given callback from a pattern. Will unsubscribe from redis
|
105
|
+
# if there are no remaining subscriptions on this pattern
|
106
|
+
#
|
107
|
+
# @return [Deferrable] Succeeds when the punsubscribe has completed or
|
108
|
+
# fails if callback could not be found. Note that success may happen
|
109
|
+
# immediately in the case that there are other callbacks for the same
|
110
|
+
# pattern (and therefore no punsubscription from redis is necessary)
|
111
|
+
#
|
112
|
+
def punsubscribe_proc(pattern, proc)
|
113
|
+
df = EM::DefaultDeferrable.new
|
114
|
+
if @psub_callbacks[pattern].delete(proc)
|
115
|
+
if @psub_callbacks[pattern].any?
|
116
|
+
# Succeed deferrable immediately - no need to punsubscribe
|
117
|
+
df.succeed
|
118
|
+
else
|
119
|
+
punsubscribe(pattern).callback { |_|
|
120
|
+
df.succeed
|
121
|
+
}
|
122
|
+
end
|
123
|
+
else
|
124
|
+
df.fail
|
125
|
+
end
|
126
|
+
return df
|
127
|
+
end
|
128
|
+
|
129
|
+
private
|
130
|
+
|
131
|
+
# Send a command to redis without adding a deferrable for it. This is
|
132
|
+
# useful for commands for which replies work or need to be treated
|
133
|
+
# differently
|
134
|
+
def raw_send_command(sym, *args)
|
135
|
+
if @connected
|
136
|
+
@connection.send_command(sym, *args)
|
137
|
+
else
|
138
|
+
callback do
|
139
|
+
@connection.send_command(sym, *args)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
return nil
|
143
|
+
end
|
144
|
+
|
145
|
+
def pubsub_deferrable(channel)
|
146
|
+
df = EM::DefaultDeferrable.new
|
147
|
+
@pubsub_defs[channel].push(df)
|
148
|
+
df
|
149
|
+
end
|
150
|
+
|
151
|
+
def handle_reply(reply)
|
152
|
+
if reply && PUBSUB_MESSAGES.include?(reply[0]) # reply can be nil
|
153
|
+
# Note: pmessage is the only message with 4 arguments
|
154
|
+
kind, subscription, d1, d2 = *reply
|
155
|
+
|
156
|
+
case kind.to_sym
|
157
|
+
when :message
|
158
|
+
if @sub_callbacks.has_key?(subscription)
|
159
|
+
@sub_callbacks[subscription].each { |cb| cb.call(d1) }
|
160
|
+
end
|
161
|
+
# Arguments are channel, message payload
|
162
|
+
emit(:message, subscription, d1)
|
163
|
+
when :pmessage
|
164
|
+
if @psub_callbacks.has_key?(subscription)
|
165
|
+
@psub_callbacks[subscription].each { |cb| cb.call(d1, d2) }
|
166
|
+
end
|
167
|
+
# Arguments are original pattern, channel, message payload
|
168
|
+
emit(:pmessage, subscription, d1, d2)
|
169
|
+
else
|
170
|
+
if @pubsub_defs[subscription].any?
|
171
|
+
df = @pubsub_defs[subscription].shift
|
172
|
+
df.succeed(d1)
|
173
|
+
# Cleanup empty arrays
|
174
|
+
if @pubsub_defs[subscription].empty?
|
175
|
+
@pubsub_defs.delete(subscription)
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
# Also emit the event, as an alternative to using the deferrables
|
180
|
+
emit(kind.to_sym, subscription, d1)
|
181
|
+
end
|
182
|
+
else
|
183
|
+
super
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe EM::Hiredis::BaseClient do
|
4
|
+
it "should be able to connect to redis (required for all tests!)" do
|
5
|
+
em {
|
6
|
+
redis = EM::Hiredis.connect
|
7
|
+
redis.callback {
|
8
|
+
done
|
9
|
+
}
|
10
|
+
redis.errback {
|
11
|
+
puts "CHECK THAT THE REDIS SERVER IS RUNNING ON PORT 6379"
|
12
|
+
fail
|
13
|
+
}
|
14
|
+
}
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should emit an event on reconnect failure, with the retry count" do
|
18
|
+
# Assumes there is no redis server on 9999
|
19
|
+
connect(1, "redis://localhost:9999/") do |redis|
|
20
|
+
expected = 1
|
21
|
+
redis.on(:reconnect_failed) { |count|
|
22
|
+
count.should == expected
|
23
|
+
expected += 1
|
24
|
+
done if expected == 3
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should emit disconnected when the connection closes" do
|
30
|
+
connect do |redis|
|
31
|
+
redis.on(:disconnected) {
|
32
|
+
done
|
33
|
+
}
|
34
|
+
redis.close_connection
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
it "should fail the client deferrable after 4 unsuccessful attempts" do
|
39
|
+
connect(1, "redis://localhost:9999/") do |redis|
|
40
|
+
events = []
|
41
|
+
redis.on(:reconnect_failed) { |count|
|
42
|
+
events << count
|
43
|
+
}
|
44
|
+
redis.errback { |error|
|
45
|
+
error.class.should == EM::Hiredis::Error
|
46
|
+
error.message.should == 'Could not connect after 4 attempts'
|
47
|
+
events.should == [1,2,3,4]
|
48
|
+
done
|
49
|
+
}
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
it "should fail commands immediately when in failed state" do
|
54
|
+
connect(1, "redis://localhost:9999/") do |redis|
|
55
|
+
redis.fail
|
56
|
+
redis.get('foo').errback { |error|
|
57
|
+
error.class.should == EM::Hiredis::Error
|
58
|
+
error.message.should == 'Redis connection in failed state'
|
59
|
+
done
|
60
|
+
}
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
it "should fail queued commands when entering failed state" do
|
65
|
+
connect(1, "redis://localhost:9999/") do |redis|
|
66
|
+
redis.get('foo').errback { |error|
|
67
|
+
error.class.should == EM::Hiredis::Error
|
68
|
+
error.message.should == 'Redis connection in failed state'
|
69
|
+
done
|
70
|
+
}
|
71
|
+
redis.fail
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
it "should allow reconfiguring the client at runtime" do
|
76
|
+
connect(1, "redis://localhost:9999/") do |redis|
|
77
|
+
redis.on(:reconnect_failed) {
|
78
|
+
redis.configure("redis://localhost:6379/9")
|
79
|
+
redis.info {
|
80
|
+
done
|
81
|
+
}
|
82
|
+
}
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
it "should allow connection to be reconnected" do
|
87
|
+
connect do |redis|
|
88
|
+
redis.on(:reconnected) {
|
89
|
+
done
|
90
|
+
}
|
91
|
+
# Wait for first connection to complete
|
92
|
+
redis.callback {
|
93
|
+
redis.reconnect_connection
|
94
|
+
}
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
it "should wrap error responses returned by redis" do
|
99
|
+
connect do |redis|
|
100
|
+
redis.sadd('foo', 'bar') {
|
101
|
+
df = redis.get('foo')
|
102
|
+
df.callback {
|
103
|
+
fail "Should have received error response from redis"
|
104
|
+
}
|
105
|
+
df.errback { |e|
|
106
|
+
e.class.should == EM::Hiredis::Error
|
107
|
+
e.message.should == 'Error reply from redis'
|
108
|
+
# This is the wrapped error from redis:
|
109
|
+
e.redis_error.message.should == 'ERR Operation against a key holding the wrong kind of value'
|
110
|
+
done
|
111
|
+
}
|
112
|
+
}
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|