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.
@@ -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