em-hiredis 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,184 +1,69 @@
1
1
  module EventMachine::Hiredis
2
- class Client
3
- PUBSUB_MESSAGES = %w{message pmessage}.freeze
4
-
5
- include EventMachine::Hiredis::EventEmitter
6
- include EM::Deferrable
7
-
8
- attr_reader :host, :port, :password, :db
9
-
2
+ class Client < BaseClient
10
3
  def self.connect(host = 'localhost', port = 6379)
11
4
  new(host, port).connect
12
5
  end
13
6
 
14
- def initialize(host, port, password = nil, db = nil)
15
- @host, @port, @password, @db = host, port, password, db
16
- @subs, @psubs, @defs = [], [], []
17
- @closing_connection = false
7
+ def monitor(&blk)
8
+ @monitoring = true
9
+ method_missing(:monitor, &blk)
18
10
  end
19
11
 
20
- def connect
21
- @connection = EM.connect(@host, @port, Connection, @host, @port)
22
-
23
- @connection.on(:closed) do
24
- if @connected
25
- @defs.each { |d| d.fail("Redis disconnected") }
26
- @defs = []
27
- @deferred_status = nil
28
- @connected = false
29
- unless @closing_connection
30
- @reconnecting = true
31
- reconnect
32
- end
33
- else
34
- unless @closing_connection
35
- EM.add_timer(1) { reconnect }
36
- end
37
- end
38
- end
39
-
40
- @connection.on(:connected) do
41
- @connected = true
42
-
43
- auth(@password) if @password
44
- select(@db) if @db
45
-
46
- @subs.each { |s| method_missing(:subscribe, s) }
47
- @psubs.each { |s| method_missing(:psubscribe, s) }
48
- succeed
49
-
50
- if @reconnecting
51
- @reconnecting = false
52
- emit(:reconnected)
12
+ def info
13
+ df = method_missing(:info)
14
+ df.callback { |response|
15
+ info = {}
16
+ response.each_line do |line|
17
+ key, value = line.split(":", 2)
18
+ info[key.to_sym] = value.chomp if value
53
19
  end
54
- end
55
-
56
- @connection.on(:message) do |reply|
57
- if RuntimeError === reply
58
- raise "Replies out of sync: #{reply.inspect}" if @defs.empty?
59
- deferred = @defs.shift
60
- deferred.fail(reply) if deferred
61
- else
62
- if reply && PUBSUB_MESSAGES.include?(reply[0]) # reply can be nil
63
- kind, subscription, d1, d2 = *reply
20
+ df.succeed(info)
21
+ }
22
+ df.callback { |info| yield info } if block_given?
23
+ df
24
+ end
64
25
 
65
- case kind.to_sym
66
- when :message
67
- emit(:message, subscription, d1)
68
- when :pmessage
69
- emit(:pmessage, subscription, d1, d2)
70
- end
71
- else
72
- if @defs.empty?
73
- if @monitoring
74
- emit(:monitor, reply)
75
- else
76
- raise "Replies out of sync: #{reply.inspect}"
77
- end
78
- else
79
- deferred = @defs.shift
80
- deferred.succeed(reply) if deferred
26
+ def info_commandstats(&blk)
27
+ hash_processor = lambda do |response|
28
+ commands = {}
29
+ response.each_line do |line|
30
+ command, data = line.split(':')
31
+ if data
32
+ c = commands[command.sub('cmdstat_', '').to_sym] = {}
33
+ data.split(',').each do |d|
34
+ k, v = d.split('=')
35
+ c[k.to_sym] = v =~ /\./ ? v.to_f : v.to_i
81
36
  end
82
37
  end
83
38
  end
39
+ blk.call(commands)
84
40
  end
85
-
86
- @connected = false
87
- @reconnecting = false
88
-
89
- return self
41
+ method_missing(:info, 'commandstats', &hash_processor)
90
42
  end
91
43
 
92
- # Indicates that commands have been sent to redis but a reply has not yet
93
- # been received
44
+ # Gives access to a richer interface for pubsub subscriptions on a
45
+ # separate redis connection
94
46
  #
95
- # This can be useful for example to avoid stopping the
96
- # eventmachine reactor while there are outstanding commands
97
- #
98
- def pending_commands?
99
- @connected && @defs.size > 0
100
- end
101
-
102
- def connected?
103
- @connected
47
+ def pubsub
48
+ @pubsub ||= begin
49
+ PubsubClient.new(@host, @port, @password, @db).connect
50
+ end
104
51
  end
105
52
 
106
- def subscribe(channel)
107
- @subs << channel
108
- method_missing(:subscribe, channel)
53
+ def subscribe(*channels)
54
+ raise "Use pubsub client"
109
55
  end
110
56
 
111
- def unsubscribe(channel)
112
- @subs.delete(channel)
113
- method_missing(:unsubscribe, channel)
57
+ def unsubscribe(*channels)
58
+ raise "Use pubsub client"
114
59
  end
115
60
 
116
61
  def psubscribe(channel)
117
- @psubs << channel
118
- method_missing(:psubscribe, channel)
62
+ raise "Use pubsub client"
119
63
  end
120
64
 
121
65
  def punsubscribe(channel)
122
- @psubs.delete(channel)
123
- method_missing(:punsubscribe, channel)
124
- end
125
-
126
- def select(db, &blk)
127
- @db = db
128
- method_missing(:select, db, &blk)
129
- end
130
-
131
- def auth(password, &blk)
132
- @password = password
133
- method_missing(:auth, password, &blk)
134
- end
135
-
136
- def monitor(&blk)
137
- @monitoring = true
138
- method_missing(:monitor, &blk)
139
- end
140
-
141
- def info(&blk)
142
- hash_processor = lambda do |response|
143
- info = {}
144
- response.each_line do |line|
145
- key, value = line.split(":", 2)
146
- info[key.to_sym] = value.chomp
147
- end
148
- blk.call(info)
149
- end
150
- method_missing(:info, &hash_processor)
151
- end
152
-
153
- def close_connection
154
- @closing_connection = true
155
- @connection.close_connection_after_writing
156
- @defs.each
157
- end
158
-
159
- private
160
-
161
- def method_missing(sym, *args)
162
- deferred = EM::DefaultDeferrable.new
163
- # Shortcut for defining the callback case with just a block
164
- deferred.callback { |result| yield(result) } if block_given?
165
-
166
- if @connected
167
- @connection.send_command(sym, *args)
168
- @defs.push(deferred)
169
- else
170
- callback do
171
- @connection.send_command(sym, *args)
172
- @defs.push(deferred)
173
- end
174
- end
175
-
176
- deferred
177
- end
178
-
179
- def reconnect
180
- EventMachine::Hiredis.logger.debug("Trying to reconnect to Redis")
181
- @connection.reconnect @host, @port
66
+ raise "Use pubsub client"
182
67
  end
183
68
  end
184
69
  end
@@ -7,10 +7,15 @@ module EventMachine::Hiredis
7
7
  def initialize(host, port)
8
8
  super
9
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
10
16
  end
11
17
 
12
18
  def connection_completed
13
- EventMachine::Hiredis.logger.info("Connected to Redis")
14
19
  @reader = ::Hiredis::Reader.new
15
20
  emit(:connected)
16
21
  end
@@ -23,12 +28,15 @@ module EventMachine::Hiredis
23
28
  end
24
29
 
25
30
  def unbind
26
- EventMachine::Hiredis.logger.info("Disconnected from Redis")
27
31
  emit(:closed)
28
32
  end
29
33
 
30
- def send_command(sym, *args)
31
- send_data(command(sym, *args))
34
+ def send_command(command, args)
35
+ send_data(command(command, *args))
36
+ end
37
+
38
+ def to_s
39
+ @name
32
40
  end
33
41
 
34
42
  protected
@@ -0,0 +1,106 @@
1
+ module EM::Hiredis
2
+ # Distributed lock built on redis
3
+ class Lock
4
+ # Register an callback which will be called 1s before the lock expires
5
+ def onexpire(&blk); @onexpire = blk; end
6
+
7
+ def initialize(redis, key, timeout)
8
+ @redis, @key, @timeout = redis, key, timeout
9
+ @locked = false
10
+ @expiry = nil
11
+ end
12
+
13
+ # Acquire the lock
14
+ #
15
+ # It is ok to call acquire again before the lock expires, which will attempt to extend the existing lock.
16
+ #
17
+ # Returns a deferrable which either succeeds if the lock can be acquired, or fails if it cannot. In both cases the expiry timestamp is returned (for the new lock or for the expired one respectively)
18
+ def acquire
19
+ df = EM::DefaultDeferrable.new
20
+ expiry = new_expiry
21
+ @redis.setnx(@key, expiry).callback { |setnx|
22
+ if setnx == 1
23
+ lock_acquired(expiry)
24
+ EM::Hiredis.logger.debug "#{to_s} Acquired new lock"
25
+ df.succeed(expiry)
26
+ else
27
+ attempt_to_acquire_existing_lock(df)
28
+ end
29
+ }.errback { |e|
30
+ df.fail(e)
31
+ }
32
+ return df
33
+ end
34
+
35
+ # Release the lock
36
+ #
37
+ # Returns a deferrable
38
+ def unlock
39
+ EM.cancel_timer(@expire_timer) if @expire_timer
40
+
41
+ unless active
42
+ df = EM::DefaultDeferrable.new
43
+ df.fail Error.new("Cannot unlock, lock not active")
44
+ return df
45
+ end
46
+
47
+ @redis.del(@key)
48
+ end
49
+
50
+ # Lock has been acquired and we're within it's expiry time
51
+ def active
52
+ @locked && Time.now.to_i < @expiry
53
+ end
54
+
55
+ # This should not be used in normal operation - force clear
56
+ def clear
57
+ @redis.del(@key)
58
+ end
59
+
60
+ def to_s
61
+ "[lock #{@key}]"
62
+ end
63
+
64
+ private
65
+
66
+ def attempt_to_acquire_existing_lock(df)
67
+ @redis.get(@key) { |expiry_1|
68
+ expiry_1 = expiry_1.to_i
69
+ if expiry_1 == @expiry || expiry_1 < Time.now.to_i
70
+ # Either the lock was ours or the lock has already expired
71
+ expiry = new_expiry
72
+ @redis.getset(@key, expiry) { |expiry_2|
73
+ expiry_2 = expiry_2.to_i
74
+ if expiry_2 == @expiry || expiry_2 < Time.now.to_i
75
+ lock_acquired(expiry)
76
+ EM::Hiredis.logger.debug "#{to_s} Acquired existing lock"
77
+ df.succeed(expiry)
78
+ else
79
+ # Another client got there first
80
+ EM::Hiredis.logger.debug "#{to_s} Could not acquire - another process acquired while we were in the process of acquiring"
81
+ df.fail(expiry_2)
82
+ end
83
+ }
84
+ else
85
+ # Someone else has an active lock
86
+ EM::Hiredis.logger.debug "#{to_s} Could not acquire - held by another process"
87
+ df.fail(expiry_1)
88
+ end
89
+ }
90
+ end
91
+
92
+ def new_expiry
93
+ Time.now.to_i + @timeout + 1
94
+ end
95
+
96
+ def lock_acquired(expiry)
97
+ @locked = true
98
+ @expiry = expiry
99
+ EM.cancel_timer(@expire_timer) if @expire_timer
100
+ @expire_timer = EM.add_timer(@timeout) {
101
+ EM::Hiredis.logger.debug "#{to_s} Expires in 1s"
102
+ @onexpire.call if @onexpire
103
+ }
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,77 @@
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
+ @lock = EM::Hiredis::Lock.new(redis, key, @timeout)
25
+ @lock.onexpire {
26
+ # When the lock is about to expire, extend (called 1s before expiry)
27
+ acquire()
28
+ }
29
+ @locked = false
30
+ EM.next_tick {
31
+ @running = true
32
+ acquire
33
+ }
34
+ end
35
+
36
+ # Acquire the lock (called automatically by initialize)
37
+ def acquire
38
+ return unless @running
39
+
40
+ @lock.acquire.callback {
41
+ if !@locked
42
+ @onlocked.call if @onlocked
43
+ @locked = true
44
+ end
45
+ }.errback { |e|
46
+ if @locked
47
+ # We were previously locked
48
+ @onunlocked.call if @onunlocked
49
+ @locked = false
50
+ end
51
+
52
+ if e.kind_of?(EM::Hiredis::RedisError)
53
+ err = e.redis_error
54
+ EM::Hiredis.logger.warn "Unexpected error acquiring #{@lock} #{err}"
55
+ end
56
+
57
+ EM.add_timer(@retry_timeout) {
58
+ acquire() unless @locked
59
+ }
60
+ }
61
+ end
62
+
63
+ def stop
64
+ @running = false
65
+ if @locked
66
+ # We were previously locked
67
+ @onunlocked.call if @onunlocked
68
+ @locked = false
69
+ end
70
+ @lock.unlock
71
+ end
72
+
73
+ def locked?
74
+ @locked
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,187 @@
1
+ module EventMachine::Hiredis
2
+ class PubsubClient < BaseClient
3
+ PUBSUB_MESSAGES = %w{message pmessage subscribe unsubscribe psubscribe punsubscribe}.freeze
4
+
5
+ def initialize(host='localhost', port='6379', password=nil, db=nil)
6
+ @subs, @psubs = [], []
7
+ @pubsub_defs = Hash.new { |h,k| h[k] = [] }
8
+ super
9
+ end
10
+
11
+ def connect
12
+ @sub_callbacks = Hash.new { |h, k| h[k] = [] }
13
+ @psub_callbacks = Hash.new { |h, k| h[k] = [] }
14
+
15
+ # Resubsubscribe to channels on reconnect
16
+ on(:reconnected) {
17
+ raw_send_command(:subscribe, @subs) if @subs.any?
18
+ raw_send_command(:psubscribe, @psubs) if @psubs.any?
19
+ }
20
+
21
+ super
22
+ end
23
+
24
+ # Subscribe to a pubsub channel
25
+ #
26
+ # If an optional proc / block is provided then it will be called when a
27
+ # message is received on this channel
28
+ #
29
+ # @return [Deferrable] Redis subscribe call
30
+ #
31
+ def subscribe(channel, proc = nil, &block)
32
+ if cb = proc || block
33
+ @sub_callbacks[channel] << cb
34
+ end
35
+ @subs << channel
36
+ raw_send_command(:subscribe, [channel])
37
+ return pubsub_deferrable(channel)
38
+ end
39
+
40
+ # Unsubscribe all callbacks for a given channel
41
+ #
42
+ # @return [Deferrable] Redis unsubscribe call
43
+ #
44
+ def unsubscribe(channel)
45
+ @sub_callbacks.delete(channel)
46
+ @subs.delete(channel)
47
+ raw_send_command(:unsubscribe, [channel])
48
+ return pubsub_deferrable(channel)
49
+ end
50
+
51
+ # Unsubscribe a given callback from a channel. Will unsubscribe from redis
52
+ # if there are no remaining subscriptions on this channel
53
+ #
54
+ # @return [Deferrable] Succeeds when the unsubscribe has completed or
55
+ # fails if callback could not be found. Note that success may happen
56
+ # immediately in the case that there are other callbacks for the same
57
+ # channel (and therefore no unsubscription from redis is necessary)
58
+ #
59
+ def unsubscribe_proc(channel, proc)
60
+ df = EM::DefaultDeferrable.new
61
+ if @sub_callbacks[channel].delete(proc)
62
+ if @sub_callbacks[channel].any?
63
+ # Succeed deferrable immediately - no need to unsubscribe
64
+ df.succeed
65
+ else
66
+ unsubscribe(channel).callback { |_|
67
+ df.succeed
68
+ }
69
+ end
70
+ else
71
+ df.fail
72
+ end
73
+ return df
74
+ end
75
+
76
+ # Pattern subscribe to a pubsub channel
77
+ #
78
+ # If an optional proc / block is provided then it will be called (with the
79
+ # channel name and message) when a message is received on a matching
80
+ # channel
81
+ #
82
+ # @return [Deferrable] Redis psubscribe call
83
+ #
84
+ def psubscribe(pattern, proc = nil, &block)
85
+ if cb = proc || block
86
+ @psub_callbacks[pattern] << cb
87
+ end
88
+ @psubs << pattern
89
+ raw_send_command(:psubscribe, [pattern])
90
+ return pubsub_deferrable(pattern)
91
+ end
92
+
93
+ # Pattern unsubscribe all callbacks for a given pattern
94
+ #
95
+ # @return [Deferrable] Redis punsubscribe call
96
+ #
97
+ def punsubscribe(pattern)
98
+ @psub_callbacks.delete(pattern)
99
+ @psubs.delete(pattern)
100
+ raw_send_command(:punsubscribe, [pattern])
101
+ return pubsub_deferrable(pattern)
102
+ end
103
+
104
+ # Unsubscribe a given callback from a pattern. Will unsubscribe from redis
105
+ # if there are no remaining subscriptions on this pattern
106
+ #
107
+ # @return [Deferrable] Succeeds when the punsubscribe has completed or
108
+ # fails if callback could not be found. Note that success may happen
109
+ # immediately in the case that there are other callbacks for the same
110
+ # pattern (and therefore no punsubscription from redis is necessary)
111
+ #
112
+ def punsubscribe_proc(pattern, proc)
113
+ df = EM::DefaultDeferrable.new
114
+ if @psub_callbacks[pattern].delete(proc)
115
+ if @psub_callbacks[pattern].any?
116
+ # Succeed deferrable immediately - no need to punsubscribe
117
+ df.succeed
118
+ else
119
+ punsubscribe(pattern).callback { |_|
120
+ df.succeed
121
+ }
122
+ end
123
+ else
124
+ df.fail
125
+ end
126
+ return df
127
+ end
128
+
129
+ private
130
+
131
+ # Send a command to redis without adding a deferrable for it. This is
132
+ # useful for commands for which replies work or need to be treated
133
+ # differently
134
+ def raw_send_command(sym, args)
135
+ if @connected
136
+ @connection.send_command(sym, args)
137
+ else
138
+ callback do
139
+ @connection.send_command(sym, args)
140
+ end
141
+ end
142
+ return nil
143
+ end
144
+
145
+ def pubsub_deferrable(channel)
146
+ df = EM::DefaultDeferrable.new
147
+ @pubsub_defs[channel].push(df)
148
+ df
149
+ end
150
+
151
+ def handle_reply(reply)
152
+ if reply && PUBSUB_MESSAGES.include?(reply[0]) # reply can be nil
153
+ # Note: pmessage is the only message with 4 arguments
154
+ kind, subscription, d1, d2 = *reply
155
+
156
+ case kind.to_sym
157
+ when :message
158
+ if @sub_callbacks.has_key?(subscription)
159
+ @sub_callbacks[subscription].each { |cb| cb.call(d1) }
160
+ end
161
+ # Arguments are channel, message payload
162
+ emit(:message, subscription, d1)
163
+ when :pmessage
164
+ if @psub_callbacks.has_key?(subscription)
165
+ @psub_callbacks[subscription].each { |cb| cb.call(d1, d2) }
166
+ end
167
+ # Arguments are original pattern, channel, message payload
168
+ emit(:pmessage, subscription, d1, d2)
169
+ else
170
+ if @pubsub_defs[subscription].any?
171
+ df = @pubsub_defs[subscription].shift
172
+ df.succeed(d1)
173
+ # Cleanup empty arrays
174
+ if @pubsub_defs[subscription].empty?
175
+ @pubsub_defs.delete(subscription)
176
+ end
177
+ end
178
+
179
+ # Also emit the event, as an alternative to using the deferrables
180
+ emit(kind.to_sym, subscription, d1)
181
+ end
182
+ else
183
+ super
184
+ end
185
+ end
186
+ end
187
+ end