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.
- 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 +182 -0
- data/Rakefile +11 -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/base_client.rb +265 -0
- data/lib/em-hiredis/client.rb +110 -0
- data/lib/em-hiredis/connection.rb +71 -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/lib/em-hiredis.rb +66 -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
- data/yapplabs-em-hiredis.gemspec +26 -0
- metadata +163 -0
@@ -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,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
|