gilmour-em-hiredis 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,88 @@
1
+ require 'securerandom'
2
+
3
+ module EM::Hiredis
4
+ # Cross-process re-entrant lock, backed by redis
5
+ class Lock
6
+
7
+ EM::Hiredis::Client.load_scripts_from(File.expand_path("../lock_lua", __FILE__))
8
+
9
+ # Register a callback which will be called 1s before the lock expires
10
+ # This is an informational callback, there is no hard guarantee on the timing
11
+ # of its invocation because the callback firing and lock key expiry are handled
12
+ # by different clocks (the client process and redis server respectively)
13
+ def onexpire(&blk); @onexpire = blk; end
14
+
15
+ def initialize(redis, key, timeout)
16
+ unless timeout.kind_of?(Fixnum) && timeout >= 1
17
+ raise "Timeout must be an integer and >= 1s"
18
+ end
19
+ @redis, @key, @timeout = redis, key, timeout
20
+ @token = SecureRandom.hex
21
+ end
22
+
23
+ # Acquire the lock
24
+ #
25
+ # This is a re-entrant lock, re-acquiring will succeed and extend the timeout
26
+ #
27
+ # Returns a deferrable which either succeeds if the lock can be acquired, or fails if it cannot.
28
+ def acquire
29
+ df = EM::DefaultDeferrable.new
30
+ @redis.lock_acquire([@key], [@token, @timeout]).callback { |success|
31
+ if (success)
32
+ EM::Hiredis.logger.debug "#{to_s} acquired"
33
+
34
+ EM.cancel_timer(@expire_timer) if @expire_timer
35
+ @expire_timer = EM.add_timer(@timeout - 1) {
36
+ EM::Hiredis.logger.debug "#{to_s} Expires in 1s"
37
+ @onexpire.call if @onexpire
38
+ }
39
+
40
+ df.succeed
41
+ else
42
+ EM::Hiredis.logger.debug "#{to_s} failed to acquire"
43
+ df.fail("Lock is not available")
44
+ end
45
+ }.errback { |e|
46
+ EM::Hiredis.logger.error "#{to_s} Error acquiring lock #{e}"
47
+ df.fail(e)
48
+ }
49
+ df
50
+ end
51
+
52
+ # Release the lock
53
+ #
54
+ # Returns a deferrable
55
+ def unlock
56
+ EM.cancel_timer(@expire_timer) if @expire_timer
57
+
58
+ df = EM::DefaultDeferrable.new
59
+ @redis.lock_release([@key], [@token]).callback { |keys_removed|
60
+ if keys_removed > 0
61
+ EM::Hiredis.logger.debug "#{to_s} released"
62
+ df.succeed
63
+ else
64
+ EM::Hiredis.logger.debug "#{to_s} could not release, not held"
65
+ df.fail("Cannot release a lock we do not hold")
66
+ end
67
+ }.errback { |e|
68
+ EM::Hiredis.logger.error "#{to_s} Error releasing lock #{e}"
69
+ df.fail(e)
70
+ }
71
+ df
72
+ end
73
+
74
+ # This should not be used in normal operation.
75
+ # Force clear without regard to who owns the lock.
76
+ def clear
77
+ EM::Hiredis.logger.warn "#{to_s} Force clearing lock (unsafe)"
78
+ EM.cancel_timer(@expire_timer) if @expire_timer
79
+
80
+ @redis.del(@key)
81
+ end
82
+
83
+ def to_s
84
+ "[lock #{@key}]"
85
+ end
86
+
87
+ end
88
+ end
@@ -0,0 +1,17 @@
1
+ -- Set key to token with expiry of timeout, if:
2
+ -- - It doesn't exist
3
+ -- - It exists and already has value of token (further set extends timeout)
4
+ -- Used to implement a re-entrant lock.
5
+ local key = KEYS[1]
6
+ local token = ARGV[1]
7
+ local timeout = ARGV[2]
8
+
9
+ local value = redis.call('get', key)
10
+
11
+ if value == token or not value then
12
+ -- Great, either we hold the lock or it's free for us to take
13
+ return redis.call('setex', key, timeout, token)
14
+ else
15
+ -- Someone else has it
16
+ return false
17
+ end
@@ -0,0 +1,9 @@
1
+ -- Deletes a key only if it has the value supplied as token
2
+ local key = KEYS[1]
3
+ local token = ARGV[1]
4
+
5
+ if redis.call('get', key) == token then
6
+ return redis.call('del', key)
7
+ else
8
+ return 0
9
+ end
@@ -0,0 +1,81 @@
1
+ module EM::Hiredis
2
+ # A lock that automatically re-acquires a lock before it loses it
3
+ #
4
+ # The lock is configured with the following two parameters
5
+ #
6
+ # :lock_timeout - Specifies how long each lock is acquired for. Setting
7
+ # this low means that locks need to be re-acquired very often, but a long
8
+ # timout means that a process that fails without cleaning up after itself
9
+ # (i.e. without releasing it's underlying lock) will block the anther
10
+ # process from picking up this lock
11
+ # replaced for a long while
12
+ # :retry_interval - Specifies how frequently to retry acquiring the lock in
13
+ # the case that the lock is held by another process, or there's an error
14
+ # communicating with redis
15
+ #
16
+ class PersistentLock
17
+ def onlocked(&blk); @onlocked = blk; self; end
18
+ def onunlocked(&blk); @onunlocked = blk; self; end
19
+
20
+ def initialize(redis, key, options = {})
21
+ @redis, @key = redis, key
22
+ @timeout = options[:lock_timeout] || 100
23
+ @retry_timeout = options[:retry_interval] || 60
24
+
25
+ @lock = EM::Hiredis::Lock.new(redis, key, @timeout)
26
+ @locked = false
27
+ EM.next_tick {
28
+ @running = true
29
+ acquire
30
+ }
31
+ end
32
+
33
+ # Acquire the lock (called automatically by initialize)
34
+ def acquire
35
+ return unless @running
36
+
37
+ @lock.acquire.callback {
38
+ if !@locked
39
+ @onlocked.call if @onlocked
40
+ @locked = true
41
+ end
42
+
43
+ # Re-acquire lock near the end of the period
44
+ @extend_timer = EM.add_timer(@timeout.to_f * 2 / 3) {
45
+ acquire()
46
+ }
47
+ }.errback { |e|
48
+ if @locked
49
+ # We were previously locked
50
+ @onunlocked.call if @onunlocked
51
+ @locked = false
52
+ end
53
+
54
+ if e.kind_of?(EM::Hiredis::RedisError)
55
+ err = e.redis_error
56
+ EM::Hiredis.logger.warn "Unexpected error acquiring #{@lock} #{err}"
57
+ end
58
+
59
+ @retry_timer = EM.add_timer(@retry_timeout) {
60
+ acquire() unless @locked
61
+ }
62
+ }
63
+ end
64
+
65
+ def stop
66
+ @running = false
67
+ EM.cancel_timer(@extend_timer) if @extend_timer
68
+ EM.cancel_timer(@retry_timer) if @retry_timer
69
+ if @locked
70
+ # We were previously locked
71
+ @onunlocked.call if @onunlocked
72
+ @locked = false
73
+ end
74
+ @lock.unlock
75
+ end
76
+
77
+ def locked?
78
+ @locked
79
+ end
80
+ end
81
+ end
@@ -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.3.0"
4
+ end
5
+ end
@@ -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