yapplabs-em-hiredis 0.4.0
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.
- checksums.yaml +7 -0
- data/.gitignore +6 -0
- data/.rspec +1 -0
- data/CHANGELOG.md +35 -0
- data/Gemfile +3 -0
- data/LICENCE +19 -0
- data/README.md +182 -0
- data/Rakefile +11 -0
- data/examples/getting_started.rb +14 -0
- data/examples/lua/sum.lua +4 -0
- data/examples/lua_example.rb +35 -0
- data/examples/pubsub_basics.rb +24 -0
- data/examples/pubsub_more.rb +51 -0
- data/examples/pubsub_raw.rb +25 -0
- data/lib/em-hiredis/base_client.rb +265 -0
- data/lib/em-hiredis/client.rb +110 -0
- data/lib/em-hiredis/connection.rb +71 -0
- data/lib/em-hiredis/event_emitter.rb +29 -0
- data/lib/em-hiredis/lock.rb +88 -0
- data/lib/em-hiredis/lock_lua/lock_acquire.lua +17 -0
- data/lib/em-hiredis/lock_lua/lock_release.lua +9 -0
- data/lib/em-hiredis/persistent_lock.rb +81 -0
- data/lib/em-hiredis/pubsub_client.rb +202 -0
- data/lib/em-hiredis/version.rb +5 -0
- data/lib/em-hiredis.rb +66 -0
- data/spec/base_client_spec.rb +118 -0
- data/spec/connection_spec.rb +56 -0
- data/spec/inactivity_check_spec.rb +66 -0
- data/spec/live_redis_protocol_spec.rb +527 -0
- data/spec/lock_spec.rb +137 -0
- data/spec/pubsub_spec.rb +314 -0
- data/spec/redis_commands_spec.rb +931 -0
- data/spec/spec_helper.rb +17 -0
- data/spec/support/connection_helper.rb +11 -0
- data/spec/support/inprocess_redis_mock.rb +83 -0
- data/spec/support/redis_mock.rb +65 -0
- data/spec/url_param_spec.rb +43 -0
- data/yapplabs-em-hiredis.gemspec +26 -0
- metadata +163 -0
@@ -0,0 +1,202 @@
|
|
1
|
+
module EventMachine::Hiredis
|
2
|
+
class PubsubClient < BaseClient
|
3
|
+
PUBSUB_MESSAGES = %w{message pmessage subscribe unsubscribe psubscribe punsubscribe}.freeze
|
4
|
+
|
5
|
+
PING_CHANNEL = '__em-hiredis-ping'
|
6
|
+
|
7
|
+
def initialize(host='localhost', port='6379', password=nil, db=nil)
|
8
|
+
@subs, @psubs = [], []
|
9
|
+
@pubsub_defs = Hash.new { |h,k| h[k] = [] }
|
10
|
+
super
|
11
|
+
end
|
12
|
+
|
13
|
+
def connect
|
14
|
+
@sub_callbacks = Hash.new { |h, k| h[k] = [] }
|
15
|
+
@psub_callbacks = Hash.new { |h, k| h[k] = [] }
|
16
|
+
|
17
|
+
# Resubsubscribe to channels on reconnect
|
18
|
+
on(:reconnected) {
|
19
|
+
raw_send_command(:subscribe, @subs) if @subs.any?
|
20
|
+
raw_send_command(:psubscribe, @psubs) if @psubs.any?
|
21
|
+
}
|
22
|
+
|
23
|
+
super
|
24
|
+
end
|
25
|
+
|
26
|
+
# Subscribe to a pubsub channel
|
27
|
+
#
|
28
|
+
# If an optional proc / block is provided then it will be called when a
|
29
|
+
# message is received on this channel
|
30
|
+
#
|
31
|
+
# @return [Deferrable] Redis subscribe call
|
32
|
+
#
|
33
|
+
def subscribe(channel, proc = nil, &block)
|
34
|
+
if cb = proc || block
|
35
|
+
@sub_callbacks[channel] << cb
|
36
|
+
end
|
37
|
+
@subs << channel
|
38
|
+
raw_send_command(:subscribe, [channel])
|
39
|
+
return pubsub_deferrable(channel)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Unsubscribe all callbacks for a given channel
|
43
|
+
#
|
44
|
+
# @return [Deferrable] Redis unsubscribe call
|
45
|
+
#
|
46
|
+
def unsubscribe(channel)
|
47
|
+
@sub_callbacks.delete(channel)
|
48
|
+
@subs.delete(channel)
|
49
|
+
raw_send_command(:unsubscribe, [channel])
|
50
|
+
return pubsub_deferrable(channel)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Unsubscribe a given callback from a channel. Will unsubscribe from redis
|
54
|
+
# if there are no remaining subscriptions on this channel
|
55
|
+
#
|
56
|
+
# @return [Deferrable] Succeeds when the unsubscribe has completed or
|
57
|
+
# fails if callback could not be found. Note that success may happen
|
58
|
+
# immediately in the case that there are other callbacks for the same
|
59
|
+
# channel (and therefore no unsubscription from redis is necessary)
|
60
|
+
#
|
61
|
+
def unsubscribe_proc(channel, proc)
|
62
|
+
df = EM::DefaultDeferrable.new
|
63
|
+
if @sub_callbacks[channel].delete(proc)
|
64
|
+
if @sub_callbacks[channel].any?
|
65
|
+
# Succeed deferrable immediately - no need to unsubscribe
|
66
|
+
df.succeed
|
67
|
+
else
|
68
|
+
unsubscribe(channel).callback { |_|
|
69
|
+
df.succeed
|
70
|
+
}
|
71
|
+
end
|
72
|
+
else
|
73
|
+
df.fail
|
74
|
+
end
|
75
|
+
return df
|
76
|
+
end
|
77
|
+
|
78
|
+
# Pattern subscribe to a pubsub channel
|
79
|
+
#
|
80
|
+
# If an optional proc / block is provided then it will be called (with the
|
81
|
+
# channel name and message) when a message is received on a matching
|
82
|
+
# channel
|
83
|
+
#
|
84
|
+
# @return [Deferrable] Redis psubscribe call
|
85
|
+
#
|
86
|
+
def psubscribe(pattern, proc = nil, &block)
|
87
|
+
if cb = proc || block
|
88
|
+
@psub_callbacks[pattern] << cb
|
89
|
+
end
|
90
|
+
@psubs << pattern
|
91
|
+
raw_send_command(:psubscribe, [pattern])
|
92
|
+
return pubsub_deferrable(pattern)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Pattern unsubscribe all callbacks for a given pattern
|
96
|
+
#
|
97
|
+
# @return [Deferrable] Redis punsubscribe call
|
98
|
+
#
|
99
|
+
def punsubscribe(pattern)
|
100
|
+
@psub_callbacks.delete(pattern)
|
101
|
+
@psubs.delete(pattern)
|
102
|
+
raw_send_command(:punsubscribe, [pattern])
|
103
|
+
return pubsub_deferrable(pattern)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Unsubscribe a given callback from a pattern. Will unsubscribe from redis
|
107
|
+
# if there are no remaining subscriptions on this pattern
|
108
|
+
#
|
109
|
+
# @return [Deferrable] Succeeds when the punsubscribe has completed or
|
110
|
+
# fails if callback could not be found. Note that success may happen
|
111
|
+
# immediately in the case that there are other callbacks for the same
|
112
|
+
# pattern (and therefore no punsubscription from redis is necessary)
|
113
|
+
#
|
114
|
+
def punsubscribe_proc(pattern, proc)
|
115
|
+
df = EM::DefaultDeferrable.new
|
116
|
+
if @psub_callbacks[pattern].delete(proc)
|
117
|
+
if @psub_callbacks[pattern].any?
|
118
|
+
# Succeed deferrable immediately - no need to punsubscribe
|
119
|
+
df.succeed
|
120
|
+
else
|
121
|
+
punsubscribe(pattern).callback { |_|
|
122
|
+
df.succeed
|
123
|
+
}
|
124
|
+
end
|
125
|
+
else
|
126
|
+
df.fail
|
127
|
+
end
|
128
|
+
return df
|
129
|
+
end
|
130
|
+
|
131
|
+
# Pubsub connections to not support even the PING command, but it is useful,
|
132
|
+
# especially with read-only connections like pubsub, to be able to check that
|
133
|
+
# the TCP connection is still usefully alive.
|
134
|
+
#
|
135
|
+
# This is not particularly elegant, but it's probably the best we can do
|
136
|
+
# for now. Ping support for pubsub connections is being considerred:
|
137
|
+
# https://github.com/antirez/redis/issues/420
|
138
|
+
def ping
|
139
|
+
subscribe(PING_CHANNEL).callback {
|
140
|
+
unsubscribe(PING_CHANNEL)
|
141
|
+
}
|
142
|
+
end
|
143
|
+
|
144
|
+
private
|
145
|
+
|
146
|
+
# Send a command to redis without adding a deferrable for it. This is
|
147
|
+
# useful for commands for which replies work or need to be treated
|
148
|
+
# differently
|
149
|
+
def raw_send_command(sym, args)
|
150
|
+
if @connected
|
151
|
+
@connection.send_command(sym, args)
|
152
|
+
else
|
153
|
+
callback do
|
154
|
+
@connection.send_command(sym, args)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
return nil
|
158
|
+
end
|
159
|
+
|
160
|
+
def pubsub_deferrable(channel)
|
161
|
+
df = EM::DefaultDeferrable.new
|
162
|
+
@pubsub_defs[channel].push(df)
|
163
|
+
df
|
164
|
+
end
|
165
|
+
|
166
|
+
def handle_reply(reply)
|
167
|
+
if reply && PUBSUB_MESSAGES.include?(reply[0]) # reply can be nil
|
168
|
+
# Note: pmessage is the only message with 4 arguments
|
169
|
+
kind, subscription, d1, d2 = *reply
|
170
|
+
|
171
|
+
case kind.to_sym
|
172
|
+
when :message
|
173
|
+
if @sub_callbacks.has_key?(subscription)
|
174
|
+
@sub_callbacks[subscription].each { |cb| cb.call(d1) }
|
175
|
+
end
|
176
|
+
# Arguments are channel, message payload
|
177
|
+
emit(:message, subscription, d1)
|
178
|
+
when :pmessage
|
179
|
+
if @psub_callbacks.has_key?(subscription)
|
180
|
+
@psub_callbacks[subscription].each { |cb| cb.call(d1, d2) }
|
181
|
+
end
|
182
|
+
# Arguments are original pattern, channel, message payload
|
183
|
+
emit(:pmessage, subscription, d1, d2)
|
184
|
+
else
|
185
|
+
if @pubsub_defs[subscription].any?
|
186
|
+
df = @pubsub_defs[subscription].shift
|
187
|
+
df.succeed(d1)
|
188
|
+
# Cleanup empty arrays
|
189
|
+
if @pubsub_defs[subscription].empty?
|
190
|
+
@pubsub_defs.delete(subscription)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
# Also emit the event, as an alternative to using the deferrables
|
195
|
+
emit(kind.to_sym, subscription, d1)
|
196
|
+
end
|
197
|
+
else
|
198
|
+
super
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
data/lib/em-hiredis.rb
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
|
3
|
+
module EventMachine
|
4
|
+
module Hiredis
|
5
|
+
# All em-hiredis errors should descend from EM::Hiredis::Error
|
6
|
+
class Error < RuntimeError; end
|
7
|
+
|
8
|
+
# An error reply from Redis. The actual error retuned by ::Hiredis will be
|
9
|
+
# wrapped in the redis_error accessor.
|
10
|
+
class RedisError < Error
|
11
|
+
attr_accessor :redis_error
|
12
|
+
end
|
13
|
+
|
14
|
+
class << self
|
15
|
+
attr_accessor :reconnect_timeout
|
16
|
+
end
|
17
|
+
self.reconnect_timeout = 0.5
|
18
|
+
|
19
|
+
def self.setup(uri = nil, tls = false)
|
20
|
+
uri = uri || ENV["REDIS_URL"] || "redis://127.0.0.1:6379/0"
|
21
|
+
client = Client.new
|
22
|
+
client.configure(uri)
|
23
|
+
client
|
24
|
+
end
|
25
|
+
|
26
|
+
# Connects to redis and returns a client instance
|
27
|
+
#
|
28
|
+
# Will connect in preference order to the provided uri, the REDIS_URL
|
29
|
+
# environment variable, or localhost:6379
|
30
|
+
#
|
31
|
+
# TCP connections are supported via redis://:password@host:port/db (only
|
32
|
+
# host and port components are required). If rediss://... is passed, TLS
|
33
|
+
# is assumed.
|
34
|
+
#
|
35
|
+
# Unix socket uris are supported, e.g. unix:///tmp/redis.sock, however
|
36
|
+
# it's not possible to set the db or password - use initialize instead in
|
37
|
+
# this case
|
38
|
+
def self.connect(uri = nil, tls = false)
|
39
|
+
client = setup(uri, tls)
|
40
|
+
client.connect
|
41
|
+
client
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.logger=(logger)
|
45
|
+
@@logger = logger
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.logger
|
49
|
+
@@logger ||= begin
|
50
|
+
require 'logger'
|
51
|
+
log = ::Logger.new(STDOUT)
|
52
|
+
log.level = ::Logger::WARN
|
53
|
+
log
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
autoload :Lock, 'em-hiredis/lock'
|
58
|
+
autoload :PersistentLock, 'em-hiredis/persistent_lock'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
require 'em-hiredis/event_emitter'
|
63
|
+
require 'em-hiredis/connection'
|
64
|
+
require 'em-hiredis/base_client'
|
65
|
+
require 'em-hiredis/client'
|
66
|
+
require 'em-hiredis/pubsub_client'
|
@@ -0,0 +1,118 @@
|
|
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::RedisError
|
107
|
+
e.should be_kind_of(EM::Hiredis::Error)
|
108
|
+
msg = "WRONGTYPE Operation against a key holding the wrong kind of value"
|
109
|
+
e.message.should == msg
|
110
|
+
# This is the wrapped error from redis:
|
111
|
+
e.redis_error.class.should == RuntimeError
|
112
|
+
e.redis_error.message.should == msg
|
113
|
+
done
|
114
|
+
}
|
115
|
+
}
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe EventMachine::Hiredis, "connecting" do
|
4
|
+
let(:replies) do
|
5
|
+
# shove db number into PING reply since redis has no way
|
6
|
+
# of exposing the currently selected DB
|
7
|
+
replies = {
|
8
|
+
:select => lambda { |db| $db = db; "+OK" },
|
9
|
+
:ping => lambda { "+PONG #{$db}" },
|
10
|
+
:auth => lambda { |password| $auth = password; "+OK" },
|
11
|
+
:get => lambda { |key| $auth == "secret" ? "$3\r\nbar" : "$-1" },
|
12
|
+
}
|
13
|
+
end
|
14
|
+
|
15
|
+
def connect_to_mock(url, &blk)
|
16
|
+
redis_mock(replies) do
|
17
|
+
connect(1, url, &blk)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
it "doesn't call select by default" do
|
22
|
+
connect_to_mock("redis://localhost:6380/") do |redis|
|
23
|
+
redis.ping do |response|
|
24
|
+
response.should == "PONG "
|
25
|
+
done
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
it "selects the right db" do
|
31
|
+
connect_to_mock("redis://localhost:6380/9") do |redis|
|
32
|
+
redis.ping do |response|
|
33
|
+
response.should == "PONG 9"
|
34
|
+
done
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
it "authenticates with a password" do
|
40
|
+
connect_to_mock("redis://:secret@localhost:6380/9") do |redis|
|
41
|
+
redis.get("foo") do |response|
|
42
|
+
response.should == "bar"
|
43
|
+
done
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
it "rejects a bad password" do
|
49
|
+
connect_to_mock("redis://:failboat@localhost:6380/9") do |redis|
|
50
|
+
redis.get("foo") do |response|
|
51
|
+
response.should be_nil
|
52
|
+
done
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'support/inprocess_redis_mock'
|
3
|
+
|
4
|
+
def connect_mock(timeout = 10, url = "redis://localhost:6381", server = nil, &blk)
|
5
|
+
em(timeout) do
|
6
|
+
IRedisMock.start
|
7
|
+
redis = EventMachine::Hiredis.connect(url)
|
8
|
+
blk.call(redis)
|
9
|
+
IRedisMock.stop
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
describe EM::Hiredis::BaseClient do
|
14
|
+
it "should ping after activity timeout reached" do
|
15
|
+
connect_mock do |redis|
|
16
|
+
redis.configure_inactivity_check(2, 1)
|
17
|
+
EM.add_timer(4) {
|
18
|
+
IRedisMock.received.should include("ping")
|
19
|
+
done
|
20
|
+
}
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
it "should not ping before activity timeout reached" do
|
25
|
+
connect_mock do |redis|
|
26
|
+
redis.configure_inactivity_check(3, 1)
|
27
|
+
EM.add_timer(2) {
|
28
|
+
IRedisMock.received.should_not include("ping")
|
29
|
+
done
|
30
|
+
}
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
it "should ping after timeout reached even though command has been sent (no response)" do
|
35
|
+
connect_mock do |redis|
|
36
|
+
redis.configure_inactivity_check(2, 1)
|
37
|
+
IRedisMock.pause # no responses from now on
|
38
|
+
|
39
|
+
EM.add_timer(1.5) {
|
40
|
+
redis.get "test"
|
41
|
+
}
|
42
|
+
|
43
|
+
EM.add_timer(4) {
|
44
|
+
IRedisMock.received.should include("ping")
|
45
|
+
done
|
46
|
+
}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
it "should trigger a reconnect when there's no response to ping" do
|
51
|
+
connect_mock do |redis|
|
52
|
+
redis.configure_inactivity_check(2, 1)
|
53
|
+
IRedisMock.pause # no responses from now on
|
54
|
+
|
55
|
+
EM.add_timer(1.5) {
|
56
|
+
redis.get "test"
|
57
|
+
}
|
58
|
+
|
59
|
+
EM.add_timer(5) {
|
60
|
+
IRedisMock.received.should include("disconnect")
|
61
|
+
done
|
62
|
+
}
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|