gilmour-em-hiredis 0.3.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.
- checksums.yaml +7 -0
- data/.gitignore +6 -0
- data/.rspec +1 -0
- data/CHANGELOG.md +35 -0
- data/Gemfile +3 -0
- data/LICENCE +19 -0
- data/README.md +178 -0
- data/Rakefile +11 -0
- data/em-hiredis.gemspec +28 -0
- data/examples/getting_started.rb +14 -0
- data/examples/lua/sum.lua +4 -0
- data/examples/lua_example.rb +35 -0
- data/examples/pubsub_basics.rb +24 -0
- data/examples/pubsub_more.rb +51 -0
- data/examples/pubsub_raw.rb +25 -0
- data/lib/em-hiredis.rb +65 -0
- data/lib/em-hiredis/base_client.rb +264 -0
- data/lib/em-hiredis/client.rb +110 -0
- data/lib/em-hiredis/connection.rb +69 -0
- data/lib/em-hiredis/event_emitter.rb +29 -0
- data/lib/em-hiredis/lock.rb +88 -0
- data/lib/em-hiredis/lock_lua/lock_acquire.lua +17 -0
- data/lib/em-hiredis/lock_lua/lock_release.lua +9 -0
- data/lib/em-hiredis/persistent_lock.rb +81 -0
- data/lib/em-hiredis/pubsub_client.rb +202 -0
- data/lib/em-hiredis/version.rb +5 -0
- data/spec/base_client_spec.rb +118 -0
- data/spec/connection_spec.rb +56 -0
- data/spec/inactivity_check_spec.rb +66 -0
- data/spec/live_redis_protocol_spec.rb +527 -0
- data/spec/lock_spec.rb +137 -0
- data/spec/pubsub_spec.rb +314 -0
- data/spec/redis_commands_spec.rb +931 -0
- data/spec/spec_helper.rb +17 -0
- data/spec/support/connection_helper.rb +11 -0
- data/spec/support/inprocess_redis_mock.rb +83 -0
- data/spec/support/redis_mock.rb +65 -0
- data/spec/url_param_spec.rb +43 -0
- metadata +163 -0
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
|