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,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