gilmour-em-hiredis 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/em-hiredis.rb ADDED
@@ -0,0 +1,65 @@
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)
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)
33
+ #
34
+ # Unix socket uris are supported, e.g. unix:///tmp/redis.sock, however
35
+ # it's not possible to set the db or password - use initialize instead in
36
+ # this case
37
+ def self.connect(uri = nil)
38
+ client = setup(uri)
39
+ client.connect
40
+ client
41
+ end
42
+
43
+ def self.logger=(logger)
44
+ @@logger = logger
45
+ end
46
+
47
+ def self.logger
48
+ @@logger ||= begin
49
+ require 'logger'
50
+ log = Logger.new(STDOUT)
51
+ log.level = Logger::WARN
52
+ log
53
+ end
54
+ end
55
+
56
+ autoload :Lock, 'em-hiredis/lock'
57
+ autoload :PersistentLock, 'em-hiredis/persistent_lock'
58
+ end
59
+ end
60
+
61
+ require 'em-hiredis/event_emitter'
62
+ require 'em-hiredis/connection'
63
+ require 'em-hiredis/base_client'
64
+ require 'em-hiredis/client'
65
+ require 'em-hiredis/pubsub_client'
@@ -0,0 +1,264 @@
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)
20
+ @host, @port, @password, @db = host, port, password, db
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
+ @password = uri.password
54
+ path = uri.path[1..-1]
55
+ @db = path.to_i # Empty path => 0
56
+ end
57
+ end
58
+
59
+ # Disconnect then reconnect the redis connection.
60
+ #
61
+ # Pass optional uri - e.g. to connect to a different redis server.
62
+ # Any pending redis commands will be failed, but during the reconnection
63
+ # new commands will be queued and sent after connected.
64
+ #
65
+ def reconnect!(new_uri = nil)
66
+ @connection.close_connection
67
+ configure(new_uri) if new_uri
68
+ @auto_reconnect = true
69
+ EM.next_tick { reconnect_connection }
70
+ end
71
+
72
+ def connect
73
+ @auto_reconnect = true
74
+ @connection = EM.connect(@host, @port, Connection, @host, @port)
75
+
76
+ @connection.on(:closed) do
77
+ cancel_inactivity_checks
78
+ if @connected
79
+ @defs.each { |d| d.fail(Error.new("Redis disconnected")) }
80
+ @defs = []
81
+ @deferred_status = nil
82
+ @connected = false
83
+ if @auto_reconnect
84
+ # Next tick avoids reconnecting after for example EM.stop
85
+ EM.next_tick { reconnect }
86
+ end
87
+ emit(:disconnected)
88
+ EM::Hiredis.logger.info("#{@connection} Disconnected")
89
+ else
90
+ if @auto_reconnect
91
+ @reconnect_failed_count += 1
92
+ @reconnect_timer = EM.add_timer(EM::Hiredis.reconnect_timeout) {
93
+ @reconnect_timer = nil
94
+ reconnect
95
+ }
96
+ emit(:reconnect_failed, @reconnect_failed_count)
97
+ EM::Hiredis.logger.info("#{@connection} Reconnect failed")
98
+
99
+ if @reconnect_failed_count >= 4
100
+ emit(:failed)
101
+ self.fail(Error.new("Could not connect after 4 attempts"))
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ @connection.on(:connected) do
108
+ @connected = true
109
+ @reconnect_failed_count = 0
110
+ @failed = false
111
+
112
+ select(@db) unless @db == 0
113
+ auth(@password) if @password
114
+
115
+ @command_queue.each do |df, command, args|
116
+ @connection.send_command(command, args)
117
+ @defs.push(df)
118
+ end
119
+ @command_queue = []
120
+
121
+ schedule_inactivity_checks
122
+
123
+ emit(:connected)
124
+ EM::Hiredis.logger.info("#{@connection} Connected")
125
+ succeed
126
+
127
+ if @reconnecting
128
+ @reconnecting = false
129
+ emit(:reconnected)
130
+ end
131
+ end
132
+
133
+ @connection.on(:message) do |reply|
134
+ if RuntimeError === reply
135
+ raise "Replies out of sync: #{reply.inspect}" if @defs.empty?
136
+ deferred = @defs.shift
137
+ error = RedisError.new(reply.message)
138
+ error.redis_error = reply
139
+ deferred.fail(error) if deferred
140
+ else
141
+ @inactive_seconds = 0
142
+ handle_reply(reply)
143
+ end
144
+ end
145
+
146
+ @connected = false
147
+ @reconnecting = false
148
+
149
+ return self
150
+ end
151
+
152
+ # Indicates that commands have been sent to redis but a reply has not yet
153
+ # been received
154
+ #
155
+ # This can be useful for example to avoid stopping the
156
+ # eventmachine reactor while there are outstanding commands
157
+ #
158
+ def pending_commands?
159
+ @connected && @defs.size > 0
160
+ end
161
+
162
+ def connected?
163
+ @connected
164
+ end
165
+
166
+ def select(db, &blk)
167
+ @db = db
168
+ method_missing(:select, db, &blk)
169
+ end
170
+
171
+ def auth(password, &blk)
172
+ @password = password
173
+ method_missing(:auth, password, &blk)
174
+ end
175
+
176
+ def close_connection
177
+ EM.cancel_timer(@reconnect_timer) if @reconnect_timer
178
+ @auto_reconnect = false
179
+ @connection.close_connection_after_writing
180
+ end
181
+
182
+ # Note: This method doesn't disconnect if already connected. You probably
183
+ # want to use `reconnect!`
184
+ def reconnect_connection
185
+ @auto_reconnect = true
186
+ EM.cancel_timer(@reconnect_timer) if @reconnect_timer
187
+ reconnect
188
+ end
189
+
190
+ # Starts an inactivity checker which will ping redis if nothing has been
191
+ # heard on the connection for `trigger_secs` seconds and forces a reconnect
192
+ # after a further `response_timeout` seconds if we still don't hear anything.
193
+ def configure_inactivity_check(trigger_secs, response_timeout)
194
+ raise ArgumentError('trigger_secs must be > 0') unless trigger_secs.to_i > 0
195
+ raise ArgumentError('response_timeout must be > 0') unless response_timeout.to_i > 0
196
+
197
+ @inactivity_trigger_secs = trigger_secs.to_i
198
+ @inactivity_response_timeout = response_timeout.to_i
199
+
200
+ # Start the inactivity check now only if we're already conected, otherwise
201
+ # the connected event will schedule it.
202
+ schedule_inactivity_checks if @connected
203
+ end
204
+
205
+ private
206
+
207
+ def method_missing(sym, *args)
208
+ deferred = EM::DefaultDeferrable.new
209
+ # Shortcut for defining the callback case with just a block
210
+ deferred.callback { |result| yield(result) } if block_given?
211
+
212
+ if @connected
213
+ @connection.send_command(sym, args)
214
+ @defs.push(deferred)
215
+ elsif @failed
216
+ deferred.fail(Error.new("Redis connection in failed state"))
217
+ else
218
+ @command_queue << [deferred, sym, args]
219
+ end
220
+
221
+ deferred
222
+ end
223
+
224
+ def reconnect
225
+ @reconnecting = true
226
+ @connection.reconnect @host, @port
227
+ EM::Hiredis.logger.info("#{@connection} Reconnecting")
228
+ end
229
+
230
+ def cancel_inactivity_checks
231
+ EM.cancel_timer(@inactivity_timer) if @inactivity_timer
232
+ @inactivity_timer = nil
233
+ end
234
+
235
+ def schedule_inactivity_checks
236
+ if @inactivity_trigger_secs
237
+ @inactive_seconds = 0
238
+ @inactivity_timer = EM.add_periodic_timer(1) {
239
+ @inactive_seconds += 1
240
+ if @inactive_seconds > @inactivity_trigger_secs + @inactivity_response_timeout
241
+ EM::Hiredis.logger.error "#{@connection} No response to ping, triggering reconnect"
242
+ reconnect!
243
+ elsif @inactive_seconds > @inactivity_trigger_secs
244
+ EM::Hiredis.logger.debug "#{@connection} Connection inactive, triggering ping"
245
+ ping
246
+ end
247
+ }
248
+ end
249
+ end
250
+
251
+ def handle_reply(reply)
252
+ if @defs.empty?
253
+ if @monitoring
254
+ emit(:monitor, reply)
255
+ else
256
+ raise "Replies out of sync: #{reply.inspect}"
257
+ end
258
+ else
259
+ deferred = @defs.shift
260
+ deferred.succeed(reply) if deferred
261
+ end
262
+ end
263
+ end
264
+ 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)
6
+ new(host, port).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_client
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,69 @@
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)
8
+ super
9
+ @host, @port = host, port
10
+ @name = "[em-hiredis #{@host}:#{@port}]"
11
+ end
12
+
13
+ def reconnect(host, port)
14
+ super
15
+ @host, @port = host, port
16
+ end
17
+
18
+ def connection_completed
19
+ @reader = ::Hiredis::Reader.new
20
+ emit(:connected)
21
+ end
22
+
23
+ def receive_data(data)
24
+ @reader.feed(data)
25
+ until (reply = @reader.gets) == false
26
+ emit(:message, reply)
27
+ end
28
+ end
29
+
30
+ def unbind
31
+ emit(:closed)
32
+ end
33
+
34
+ def send_command(command, args)
35
+ send_data(command(command, *args))
36
+ end
37
+
38
+ def to_s
39
+ @name
40
+ end
41
+
42
+ protected
43
+
44
+ COMMAND_DELIMITER = "\r\n"
45
+
46
+ def command(*args)
47
+ command = []
48
+ command << "*#{args.size}"
49
+
50
+ args.each do |arg|
51
+ arg = arg.to_s
52
+ command << "$#{string_size arg}"
53
+ command << arg
54
+ end
55
+
56
+ command.join(COMMAND_DELIMITER) + COMMAND_DELIMITER
57
+ end
58
+
59
+ if "".respond_to?(:bytesize)
60
+ def string_size(string)
61
+ string.to_s.bytesize
62
+ end
63
+ else
64
+ def string_size(string)
65
+ string.to_s.size
66
+ end
67
+ end
68
+ end
69
+ end