yapplabs-em-hiredis 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,5 @@
1
+ module EventMachine
2
+ module Hiredis
3
+ VERSION = "0.4.0"
4
+ end
5
+ 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