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,265 @@
1
+ require 'uri'
2
+
3
+ module EventMachine::Hiredis
4
+ # Emits the following events
5
+ #
6
+ # * :connected - on successful connection or reconnection
7
+ # * :reconnected - on successful reconnection
8
+ # * :disconnected - no longer connected, when previously in connected state
9
+ # * :reconnect_failed(failure_number) - a reconnect attempt failed
10
+ # This event is passed number of failures so far (1,2,3...)
11
+ # * :monitor
12
+ #
13
+ class BaseClient
14
+ include EventEmitter
15
+ include EM::Deferrable
16
+
17
+ attr_reader :host, :port, :password, :db
18
+
19
+ def initialize(host = 'localhost', port = 6379, password = nil, db = nil, tls = false)
20
+ @host, @port, @password, @db, @tls = host, port, password, db, tls
21
+ @defs = []
22
+ @command_queue = []
23
+
24
+ @reconnect_failed_count = 0
25
+ @reconnect_timer = nil
26
+ @failed = false
27
+
28
+ @inactive_seconds = 0
29
+
30
+ self.on(:failed) {
31
+ @failed = true
32
+ @command_queue.each do |df, _, _|
33
+ df.fail(Error.new("Redis connection in failed state"))
34
+ end
35
+ @command_queue = []
36
+ }
37
+ end
38
+
39
+ # Configure the redis connection to use
40
+ #
41
+ # In usual operation, the uri should be passed to initialize. This method
42
+ # is useful for example when failing over to a slave connection at runtime
43
+ #
44
+ def configure(uri_string)
45
+ uri = URI(uri_string)
46
+
47
+ if uri.scheme == "unix"
48
+ @host = uri.path
49
+ @port = nil
50
+ else
51
+ @host = uri.host
52
+ @port = uri.port
53
+ @tls = uri.scheme == 'rediss'
54
+ @password = uri.password
55
+ path = uri.path[1..-1]
56
+ @db = path.to_i # Empty path => 0
57
+ end
58
+ end
59
+
60
+ # Disconnect then reconnect the redis connection.
61
+ #
62
+ # Pass optional uri - e.g. to connect to a different redis server.
63
+ # Any pending redis commands will be failed, but during the reconnection
64
+ # new commands will be queued and sent after connected.
65
+ #
66
+ def reconnect!(new_uri = nil)
67
+ @connection.close_connection
68
+ configure(new_uri) if new_uri
69
+ @auto_reconnect = true
70
+ EM.next_tick { reconnect_connection }
71
+ end
72
+
73
+ def connect
74
+ @auto_reconnect = true
75
+ @connection = EM.connect(@host, @port, Connection, @host, @port, @tls)
76
+
77
+ @connection.on(:closed) do
78
+ cancel_inactivity_checks
79
+ if @connected
80
+ @defs.each { |d| d.fail(Error.new("Redis disconnected")) }
81
+ @defs = []
82
+ @deferred_status = nil
83
+ @connected = false
84
+ if @auto_reconnect
85
+ # Next tick avoids reconnecting after for example EM.stop
86
+ EM.next_tick { reconnect }
87
+ end
88
+ emit(:disconnected)
89
+ EM::Hiredis.logger.info("#{@connection} Disconnected")
90
+ else
91
+ if @auto_reconnect
92
+ @reconnect_failed_count += 1
93
+ @reconnect_timer = EM.add_timer(EM::Hiredis.reconnect_timeout) {
94
+ @reconnect_timer = nil
95
+ reconnect
96
+ }
97
+ emit(:reconnect_failed, @reconnect_failed_count)
98
+ EM::Hiredis.logger.info("#{@connection} Reconnect failed")
99
+
100
+ if @reconnect_failed_count >= 4
101
+ emit(:failed)
102
+ self.fail(Error.new("Could not connect after 4 attempts"))
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ @connection.on(:connected) do
109
+ @connected = true
110
+ @reconnect_failed_count = 0
111
+ @failed = false
112
+
113
+ auth(@password) if @password && @password != ''
114
+ select(@db) unless @db == 0
115
+
116
+ @command_queue.each do |df, command, args|
117
+ @connection.send_command(command, args)
118
+ @defs.push(df)
119
+ end
120
+ @command_queue = []
121
+
122
+ schedule_inactivity_checks
123
+
124
+ emit(:connected)
125
+ EM::Hiredis.logger.info("#{@connection} Connected")
126
+ succeed
127
+
128
+ if @reconnecting
129
+ @reconnecting = false
130
+ emit(:reconnected)
131
+ end
132
+ end
133
+
134
+ @connection.on(:message) do |reply|
135
+ if RuntimeError === reply
136
+ raise "Replies out of sync: #{reply.inspect}" if @defs.empty?
137
+ deferred = @defs.shift
138
+ error = RedisError.new(reply.message)
139
+ error.redis_error = reply
140
+ deferred.fail(error) if deferred
141
+ else
142
+ @inactive_seconds = 0
143
+ handle_reply(reply)
144
+ end
145
+ end
146
+
147
+ @connected = false
148
+ @reconnecting = false
149
+
150
+ return self
151
+ end
152
+
153
+ # Indicates that commands have been sent to redis but a reply has not yet
154
+ # been received
155
+ #
156
+ # This can be useful for example to avoid stopping the
157
+ # eventmachine reactor while there are outstanding commands
158
+ #
159
+ def pending_commands?
160
+ @connected && @defs.size > 0
161
+ end
162
+
163
+ def connected?
164
+ @connected
165
+ end
166
+
167
+ def select(db, &blk)
168
+ @db = db
169
+ method_missing(:select, db, &blk)
170
+ end
171
+
172
+ def auth(password, &blk)
173
+ @password = password
174
+ method_missing(:auth, password, &blk)
175
+ end
176
+
177
+ def close_connection
178
+ EM.cancel_timer(@reconnect_timer) if @reconnect_timer
179
+ @auto_reconnect = false
180
+ @connection.close_connection_after_writing
181
+ end
182
+
183
+ # Note: This method doesn't disconnect if already connected. You probably
184
+ # want to use `reconnect!`
185
+ def reconnect_connection
186
+ @auto_reconnect = true
187
+ EM.cancel_timer(@reconnect_timer) if @reconnect_timer
188
+ reconnect
189
+ end
190
+
191
+ # Starts an inactivity checker which will ping redis if nothing has been
192
+ # heard on the connection for `trigger_secs` seconds and forces a reconnect
193
+ # after a further `response_timeout` seconds if we still don't hear anything.
194
+ def configure_inactivity_check(trigger_secs, response_timeout)
195
+ raise ArgumentError('trigger_secs must be > 0') unless trigger_secs.to_i > 0
196
+ raise ArgumentError('response_timeout must be > 0') unless response_timeout.to_i > 0
197
+
198
+ @inactivity_trigger_secs = trigger_secs.to_i
199
+ @inactivity_response_timeout = response_timeout.to_i
200
+
201
+ # Start the inactivity check now only if we're already conected, otherwise
202
+ # the connected event will schedule it.
203
+ schedule_inactivity_checks if @connected
204
+ end
205
+
206
+ private
207
+
208
+ def method_missing(sym, *args)
209
+ deferred = EM::DefaultDeferrable.new
210
+ # Shortcut for defining the callback case with just a block
211
+ deferred.callback { |result| yield(result) } if block_given?
212
+
213
+ if @connected
214
+ @connection.send_command(sym, args)
215
+ @defs.push(deferred)
216
+ elsif @failed
217
+ deferred.fail(Error.new("Redis connection in failed state"))
218
+ else
219
+ @command_queue << [deferred, sym, args]
220
+ end
221
+
222
+ deferred
223
+ end
224
+
225
+ def reconnect
226
+ @reconnecting = true
227
+ @connection.reconnect @host, @port, @tls
228
+ EM::Hiredis.logger.info("#{@connection} Reconnecting")
229
+ end
230
+
231
+ def cancel_inactivity_checks
232
+ EM.cancel_timer(@inactivity_timer) if @inactivity_timer
233
+ @inactivity_timer = nil
234
+ end
235
+
236
+ def schedule_inactivity_checks
237
+ if @inactivity_trigger_secs
238
+ @inactive_seconds = 0
239
+ @inactivity_timer = EM.add_periodic_timer(1) {
240
+ @inactive_seconds += 1
241
+ if @inactive_seconds > @inactivity_trigger_secs + @inactivity_response_timeout
242
+ EM::Hiredis.logger.error "#{@connection} No response to ping, triggering reconnect"
243
+ reconnect!
244
+ elsif @inactive_seconds > @inactivity_trigger_secs
245
+ EM::Hiredis.logger.debug "#{@connection} Connection inactive, triggering ping"
246
+ ping
247
+ end
248
+ }
249
+ end
250
+ end
251
+
252
+ def handle_reply(reply)
253
+ if @defs.empty?
254
+ if @monitoring
255
+ emit(:monitor, reply)
256
+ else
257
+ raise "Replies out of sync: #{reply.inspect}"
258
+ end
259
+ else
260
+ deferred = @defs.shift
261
+ deferred.succeed(reply) if deferred
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,110 @@
1
+ require 'digest/sha1'
2
+
3
+ module EventMachine::Hiredis
4
+ class Client < BaseClient
5
+ def self.connect(host = 'localhost', port = 6379, tls = false)
6
+ new(host, port, tls).connect
7
+ end
8
+
9
+ def self.load_scripts_from(dir)
10
+ Dir.glob("#{dir}/*.lua").each do |f|
11
+ name = Regexp.new(/([^\/]*)\.lua$/).match(f)[1]
12
+ lua = File.open(f, 'r').read
13
+ EM::Hiredis.logger.debug { "Registering script: #{name}" }
14
+ EM::Hiredis::Client.register_script(name, lua)
15
+ end
16
+ end
17
+
18
+ def self.register_script(name, lua)
19
+ sha = Digest::SHA1.hexdigest(lua)
20
+ self.send(:define_method, name.to_sym) { |keys, args=[]|
21
+ eval_script(lua, sha, keys, args)
22
+ }
23
+ end
24
+
25
+ def register_script(name, lua)
26
+ sha = Digest::SHA1.hexdigest(lua)
27
+ singleton = class << self; self end
28
+ singleton.send(:define_method, name.to_sym) { |keys, args=[]|
29
+ eval_script(lua, sha, keys, args)
30
+ }
31
+ end
32
+
33
+ def eval_script(lua, lua_sha, keys, args)
34
+ df = EM::DefaultDeferrable.new
35
+ method_missing(:evalsha, lua_sha, keys.size, *keys, *args).callback(
36
+ &df.method(:succeed)
37
+ ).errback { |e|
38
+ if e.kind_of?(RedisError) && e.redis_error.message.start_with?("NOSCRIPT")
39
+ self.eval(lua, keys.size, *keys, *args)
40
+ .callback(&df.method(:succeed)).errback(&df.method(:fail))
41
+ else
42
+ df.fail(e)
43
+ end
44
+ }
45
+ df
46
+ end
47
+
48
+ def monitor(&blk)
49
+ @monitoring = true
50
+ method_missing(:monitor, &blk)
51
+ end
52
+
53
+ def info
54
+ df = method_missing(:info)
55
+ df.callback { |response|
56
+ info = {}
57
+ response.each_line do |line|
58
+ key, value = line.split(":", 2)
59
+ info[key.to_sym] = value.chomp if value
60
+ end
61
+ df.succeed(info)
62
+ }
63
+ df.callback { |info| yield info } if block_given?
64
+ df
65
+ end
66
+
67
+ def info_commandstats(&blk)
68
+ hash_processor = lambda do |response|
69
+ commands = {}
70
+ response.each_line do |line|
71
+ command, data = line.split(':')
72
+ if data
73
+ c = commands[command.sub('cmdstat_', '').to_sym] = {}
74
+ data.split(',').each do |d|
75
+ k, v = d.split('=')
76
+ c[k.to_sym] = v =~ /\./ ? v.to_f : v.to_i
77
+ end
78
+ end
79
+ end
80
+ blk.call(commands)
81
+ end
82
+ method_missing(:info, 'commandstats', &hash_processor)
83
+ end
84
+
85
+ # Gives access to a richer interface for pubsub subscriptions on a
86
+ # separate redis connection
87
+ #
88
+ def pubsub
89
+ @pubsub ||= begin
90
+ PubsubClient.new(@host, @port, @password, @db).connect
91
+ end
92
+ end
93
+
94
+ def subscribe(*channels)
95
+ raise "Use pubsub client"
96
+ end
97
+
98
+ def unsubscribe(*channels)
99
+ raise "Use pubsub client"
100
+ end
101
+
102
+ def psubscribe(channel)
103
+ raise "Use pubsub client"
104
+ end
105
+
106
+ def punsubscribe(channel)
107
+ raise "Use pubsub client"
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,71 @@
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, tls = false)
8
+ super
9
+ @host, @port, @tls = host, port, tls
10
+ @name = "[em-hiredis #{@host}:#{@port} tls:#{@tls}]"
11
+ end
12
+
13
+ def reconnect(host, port, tls = false)
14
+ super(host, port)
15
+ @host, @port, @tls = host, port, tls
16
+ end
17
+
18
+ def connection_completed
19
+ @reader = ::Hiredis::Reader.new
20
+ tls_options = @tls == true ? { ssl_version: :tlsv1_2 } : @tls
21
+ start_tls(tls_options) if tls_options
22
+ emit(:connected)
23
+ end
24
+
25
+ def receive_data(data)
26
+ @reader.feed(data)
27
+ until (reply = @reader.gets) == false
28
+ emit(:message, reply)
29
+ end
30
+ end
31
+
32
+ def unbind
33
+ emit(:closed)
34
+ end
35
+
36
+ def send_command(command, args)
37
+ send_data(command(command, *args))
38
+ end
39
+
40
+ def to_s
41
+ @name
42
+ end
43
+
44
+ protected
45
+
46
+ COMMAND_DELIMITER = "\r\n"
47
+
48
+ def command(*args)
49
+ command = []
50
+ command << "*#{args.size}"
51
+
52
+ args.each do |arg|
53
+ arg = arg.to_s
54
+ command << "$#{string_size arg}"
55
+ command << arg
56
+ end
57
+
58
+ command.join(COMMAND_DELIMITER) + COMMAND_DELIMITER
59
+ end
60
+
61
+ if "".respond_to?(:bytesize)
62
+ def string_size(string)
63
+ string.to_s.bytesize
64
+ end
65
+ else
66
+ def string_size(string)
67
+ string.to_s.size
68
+ end
69
+ end
70
+ end
71
+ 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,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?(Integer) && 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