beetle 0.1 → 0.2.1

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.
Files changed (48) hide show
  1. data/README.rdoc +18 -8
  2. data/beetle.gemspec +37 -121
  3. data/bin/beetle +9 -0
  4. data/examples/README.rdoc +0 -2
  5. data/examples/rpc.rb +3 -2
  6. data/ext/mkrf_conf.rb +19 -0
  7. data/lib/beetle.rb +2 -2
  8. data/lib/beetle/base.rb +1 -8
  9. data/lib/beetle/client.rb +16 -14
  10. data/lib/beetle/commands.rb +30 -0
  11. data/lib/beetle/commands/configuration_client.rb +73 -0
  12. data/lib/beetle/commands/configuration_server.rb +85 -0
  13. data/lib/beetle/configuration.rb +70 -7
  14. data/lib/beetle/deduplication_store.rb +50 -38
  15. data/lib/beetle/handler.rb +2 -5
  16. data/lib/beetle/logging.rb +7 -0
  17. data/lib/beetle/message.rb +11 -13
  18. data/lib/beetle/publisher.rb +12 -4
  19. data/lib/beetle/r_c.rb +2 -1
  20. data/lib/beetle/redis_configuration_client.rb +136 -0
  21. data/lib/beetle/redis_configuration_server.rb +301 -0
  22. data/lib/beetle/redis_ext.rb +79 -0
  23. data/lib/beetle/redis_master_file.rb +35 -0
  24. data/lib/beetle/redis_server_info.rb +65 -0
  25. data/lib/beetle/subscriber.rb +4 -1
  26. data/test/beetle/configuration_test.rb +14 -2
  27. data/test/beetle/deduplication_store_test.rb +61 -43
  28. data/test/beetle/message_test.rb +28 -4
  29. data/test/beetle/publisher_test.rb +17 -3
  30. data/test/beetle/redis_configuration_client_test.rb +97 -0
  31. data/test/beetle/redis_configuration_server_test.rb +278 -0
  32. data/test/beetle/redis_ext_test.rb +71 -0
  33. data/test/beetle/redis_master_file_test.rb +39 -0
  34. data/test/test_helper.rb +13 -1
  35. metadata +162 -69
  36. data/.gitignore +0 -5
  37. data/MIT-LICENSE +0 -20
  38. data/Rakefile +0 -114
  39. data/TODO +0 -7
  40. data/etc/redis-master.conf +0 -189
  41. data/etc/redis-slave.conf +0 -189
  42. data/examples/redis_failover.rb +0 -65
  43. data/script/start_rabbit +0 -29
  44. data/snafu.rb +0 -55
  45. data/test/beetle.yml +0 -81
  46. data/test/beetle/bla.rb +0 -0
  47. data/tmp/master/.gitignore +0 -2
  48. data/tmp/slave/.gitignore +0 -3
data/lib/beetle/r_c.rb CHANGED
@@ -27,7 +27,7 @@ module Beetle
27
27
  end
28
28
 
29
29
  rc :OK
30
- rc :Ancient, :failure
30
+ rc :Ancient
31
31
  rc :AttemptsLimitReached, :failure
32
32
  rc :ExceptionsLimitReached, :failure
33
33
  rc :Delayed, :recover
@@ -35,6 +35,7 @@ module Beetle
35
35
  rc :HandlerNotYetTimedOut, :recover
36
36
  rc :MutexLocked, :recover
37
37
  rc :InternalError, :recover
38
+ rc :DecodingError, :failure
38
39
 
39
40
  end
40
41
  end
@@ -0,0 +1,136 @@
1
+ module Beetle
2
+ # A RedisConfigurationClient is the subordinate part of beetle's
3
+ # redis failover solution
4
+ #
5
+ # An instance of RedisConfigurationClient lives on every server that
6
+ # hosts message consumers (worker server).
7
+ #
8
+ # It is responsible for determining an initial redis master and reacting to redis master
9
+ # switches initiated by the RedisConfigurationServer.
10
+ #
11
+ # It will write the current redis master host:port string to a file specified via a
12
+ # Configuration, which is then read by DeduplicationStore on redis access.
13
+ #
14
+ # Usually started via <tt>beetle configuration_client</tt> command.
15
+ class RedisConfigurationClient
16
+ include Logging
17
+ include RedisMasterFile
18
+
19
+ # Set a custom unique id for this instance. Must match an entry in
20
+ # Configuration#redis_configuration_client_ids.
21
+ attr_writer :id
22
+
23
+ # The current redis master
24
+ attr_reader :current_master
25
+
26
+ # Unique id for this instance (defaults to the hostname)
27
+ def id
28
+ @id ||= `hostname`.chomp
29
+ end
30
+
31
+ def initialize #:nodoc:
32
+ @current_token = nil
33
+ MessageDispatcher.configuration_client = self
34
+ end
35
+
36
+ # determinines the initial redis master (if possible), then enters a messaging event
37
+ # loop, reacting to failover related messages sent by RedisConfigurationServer.
38
+ def start
39
+ verify_redis_master_file_string
40
+ logger.info "RedisConfigurationClient starting (client id: #{id})"
41
+ determine_initial_master
42
+ clear_redis_master_file unless current_master.try(:master?)
43
+ logger.info "Listening"
44
+ beetle.listen
45
+ end
46
+
47
+ # called by the message dispatcher when a "pong" message from RedisConfigurationServer is received
48
+ def ping(payload)
49
+ token = payload["token"]
50
+ logger.info "Received ping message with token '#{token}'"
51
+ pong! if redeem_token(token)
52
+ end
53
+
54
+ # called by the message dispatcher when a "invalidate" message from RedisConfigurationServer is received
55
+ def invalidate(payload)
56
+ token = payload["token"]
57
+ logger.info "Received invalidate message with token '#{token}'"
58
+ invalidate! if redeem_token(token) && !current_master.try(:master?)
59
+ end
60
+
61
+ # called by the message dispatcher when a "reconfigure"" message from RedisConfigurationServer is received
62
+ def reconfigure(payload)
63
+ server = payload["server"]
64
+ token = payload["token"]
65
+ logger.info "Received reconfigure message with server '#{server}' and token '#{token}'"
66
+ return unless redeem_token(token)
67
+ unless server == read_redis_master_file
68
+ new_master!(server)
69
+ write_redis_master_file(server)
70
+ end
71
+ end
72
+
73
+ # Beetle::Client instance for communication with the RedisConfigurationServer
74
+ def beetle
75
+ @beetle ||= build_beetle
76
+ end
77
+
78
+ def config #:nodoc:
79
+ beetle.config
80
+ end
81
+
82
+ private
83
+
84
+ def determine_initial_master
85
+ if master_file_exists? && server = read_redis_master_file
86
+ new_master!(server)
87
+ end
88
+ end
89
+
90
+ def new_master!(server)
91
+ @current_master = Redis.from_server_string(server, :timeout => 3)
92
+ end
93
+
94
+ def build_beetle
95
+ system = Beetle.config.system_name
96
+ Beetle::Client.new.configure :exchange => system, :auto_delete => true do |config|
97
+ config.message :ping
98
+ config.queue :ping, :amqp_name => "#{system}_ping_#{id}"
99
+ config.message :pong
100
+ config.message :invalidate
101
+ config.queue :invalidate, :amqp_name => "#{system}_invalidate_#{id}"
102
+ config.message :client_invalidated
103
+ config.message :reconfigure
104
+ config.queue :reconfigure, :amqp_name => "#{system}_reconfigure_#{id}"
105
+
106
+ config.handler [:ping, :invalidate, :reconfigure], MessageDispatcher
107
+ end
108
+ end
109
+
110
+ def redeem_token(token)
111
+ @current_token = token if @current_token.nil? || token > @current_token
112
+ token_valid = token >= @current_token
113
+ logger.info "Ignored message (token was '#{token}', but expected to be >= '#{@current_token}')" unless token_valid
114
+ token_valid
115
+ end
116
+
117
+ def pong!
118
+ beetle.publish(:pong, {"id" => id, "token" => @current_token}.to_json)
119
+ end
120
+
121
+ def invalidate!
122
+ @current_master = nil
123
+ clear_redis_master_file
124
+ beetle.publish(:client_invalidated, {"id" => id, "token" => @current_token}.to_json)
125
+ end
126
+
127
+
128
+ # Dispatches messages from the queue to methods in RedisConfigurationClient
129
+ class MessageDispatcher < Beetle::Handler #:nodoc:
130
+ cattr_accessor :configuration_client
131
+ def process
132
+ @@configuration_client.__send__(message.header.routing_key, ActiveSupport::JSON.decode(message.data))
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,301 @@
1
+ module Beetle
2
+ # A RedisConfigurationServer is the supervisor part of beetle's
3
+ # redis failover solution
4
+ #
5
+ # An single instance of RedisConfigurationServer works as a supervisor for
6
+ # several RedisConfigurationClient instances. It is responsible for watching
7
+ # the redis master and electing and publishing a new master in case of failure.
8
+ #
9
+ # It will make sure that all configured RedisConfigurationClient instances
10
+ # do not use the old master anymore before making a switch, to prevent
11
+ # inconsistent data.
12
+ #
13
+ # Usually started via <tt>beetle configuration_server</tt> command.
14
+ class RedisConfigurationServer
15
+ include Logging
16
+ include RedisMasterFile
17
+
18
+ # the current redis master
19
+ attr_reader :current_master
20
+
21
+ # the current token used to detect correct message order
22
+ attr_reader :current_token
23
+
24
+ def initialize #:nodoc:
25
+ @client_ids = Set.new(config.redis_configuration_client_ids.split(","))
26
+ @current_token = (Time.now.to_f * 1000).to_i
27
+ @client_pong_ids_received = Set.new
28
+ @client_invalidated_ids_received = Set.new
29
+ MessageDispatcher.configuration_server = self
30
+ end
31
+
32
+ # Redis system status information (an instance of class RedisServerInfo)
33
+ def redis
34
+ @redis ||= RedisServerInfo.new(config, :timeout => 3)
35
+ end
36
+
37
+ # Beetle::Client instance for communication with the RedisConfigurationServer
38
+ def beetle
39
+ @beetle ||= build_beetle
40
+ end
41
+
42
+ def config #:nodoc:
43
+ beetle.config
44
+ end
45
+
46
+ # start watching redis
47
+ def start
48
+ verify_redis_master_file_string
49
+ check_redis_configuration
50
+ redis.refresh
51
+ determine_initial_master
52
+ log_start
53
+ beetle.listen do
54
+ master_watcher.watch
55
+ end
56
+ end
57
+
58
+ # test if redis is currently being watched
59
+ def paused?
60
+ master_watcher.paused?
61
+ end
62
+
63
+ # called by the message dispatcher when a "pong" message from a RedisConfigurationClient is received
64
+ def pong(payload)
65
+ id = payload["id"]
66
+ token = payload["token"]
67
+ logger.info "Received pong message from id '#{id}' with token '#{token}'"
68
+ return unless redeem_token(token)
69
+ @client_pong_ids_received << id
70
+ if all_client_pong_ids_received?
71
+ logger.debug "All client pong messages received"
72
+ @available_timer.cancel if @available_timer
73
+ invalidate_current_master
74
+ end
75
+ end
76
+
77
+ # called by the message dispatcher when a "client_invalidated" message from a RedisConfigurationClient is received
78
+ def client_invalidated(payload)
79
+ id = payload["id"]
80
+ token = payload["token"]
81
+ logger.info "Received client_invalidated message from id '#{id}' with token '#{token}'"
82
+ return unless redeem_token(token)
83
+ @client_invalidated_ids_received << id
84
+ if all_client_invalidated_ids_received?
85
+ logger.debug "All client invalidated messages received"
86
+ @invalidate_timer.cancel if @invalidate_timer
87
+ switch_master
88
+ end
89
+ end
90
+
91
+ # called from RedisWatcher when watched redis becomes unavailable
92
+ def master_unavailable!
93
+ msg = "Redis master '#{current_master.server}' not available"
94
+ master_watcher.pause
95
+ logger.warn(msg)
96
+ beetle.publish(:system_notification, {"message" => msg}.to_json)
97
+
98
+ if @client_ids.empty?
99
+ switch_master
100
+ else
101
+ start_invalidation
102
+ end
103
+ end
104
+
105
+ # called from RedisWatcher when watched redis is available
106
+ def master_available!
107
+ publish_master(current_master)
108
+ configure_slaves(current_master)
109
+ end
110
+
111
+ # check whether the current master is still online and still a master
112
+ def master_available?
113
+ redis.masters.include?(current_master)
114
+ end
115
+
116
+ private
117
+
118
+ def check_redis_configuration
119
+ raise ConfigurationError.new("Redis failover needs two or more redis servers") if redis.instances.size < 2
120
+ end
121
+
122
+ def log_start
123
+ logger.info "RedisConfigurationServer starting"
124
+ logger.info "AMQP servers : #{config.servers}"
125
+ logger.info "Client ids : #{config.redis_configuration_client_ids}"
126
+ logger.info "Redis servers : #{config.redis_servers}"
127
+ logger.info "Redis master : #{current_master.server}"
128
+ end
129
+
130
+ def build_beetle
131
+ system = Beetle.config.system_name
132
+ Beetle::Client.new.configure :exchange => system, :auto_delete => true do |config|
133
+ config.message :client_invalidated
134
+ config.queue :client_invalidated, :amqp_name => "#{system}_client_invalidated"
135
+ config.message :pong
136
+ config.queue :pong, :amqp_name => "#{system}_pong"
137
+ config.message :ping
138
+ config.message :invalidate
139
+ config.message :reconfigure
140
+ config.message :system_notification
141
+
142
+ config.handler [:pong, :client_invalidated], MessageDispatcher
143
+ end
144
+ end
145
+
146
+ def master_watcher
147
+ @master_watcher ||= RedisWatcher.new(self)
148
+ end
149
+
150
+ def determine_initial_master
151
+ if master_file_exists? && @current_master = redis_master_from_master_file
152
+ if redis.slaves.include?(current_master)
153
+ master_unavailable!
154
+ elsif redis.unknowns.include?(current_master)
155
+ master_unavailable!
156
+ end
157
+ else
158
+ write_redis_master_file(current_master.server) if @current_master = redis.auto_detect_master
159
+ end
160
+ current_master or raise NoRedisMaster.new("failed to determine initial redis master")
161
+ end
162
+
163
+ def determine_new_master
164
+ redis.unknowns.include?(current_master) ? redis.slaves_of(current_master).first : current_master
165
+ end
166
+
167
+ def redeem_token(token)
168
+ valid_token = token == @current_token
169
+ logger.info "Ignored message (token was '#{token.inspect}', but expected '#{@current_token.inspect}')" unless valid_token
170
+ valid_token
171
+ end
172
+
173
+ def start_invalidation
174
+ @client_pong_ids_received.clear
175
+ @client_invalidated_ids_received.clear
176
+ check_all_clients_available
177
+ end
178
+
179
+ def check_all_clients_available
180
+ generate_new_token
181
+ beetle.publish(:ping, payload_with_current_token)
182
+ @available_timer = EM::Timer.new(config.redis_configuration_client_timeout) { cancel_invalidation }
183
+ end
184
+
185
+ def invalidate_current_master
186
+ generate_new_token
187
+ beetle.publish(:invalidate, payload_with_current_token)
188
+ @invalidate_timer = EM::Timer.new(config.redis_configuration_client_timeout) { cancel_invalidation }
189
+ end
190
+
191
+ def cancel_invalidation
192
+ logger.warn "Redis master invalidation cancelled: 'pong' received from '#{@client_pong_ids_received.to_a.join(',')}', 'client_invalidated' received from '#{@client_invalidated_ids_received.to_a.join(',')}'"
193
+ generate_new_token
194
+ master_watcher.continue
195
+ end
196
+
197
+ def generate_new_token
198
+ @current_token += 1
199
+ end
200
+
201
+ def payload_with_current_token(message = {})
202
+ message["token"] = @current_token
203
+ message.to_json
204
+ end
205
+
206
+ def all_client_pong_ids_received?
207
+ @client_ids == @client_pong_ids_received
208
+ end
209
+
210
+ def all_client_invalidated_ids_received?
211
+ @client_ids == @client_invalidated_ids_received
212
+ end
213
+
214
+ def switch_master
215
+ if new_master = determine_new_master
216
+ msg = "Setting redis master to '#{new_master.server}' (was '#{current_master.server}')"
217
+ logger.warn(msg)
218
+ beetle.publish(:system_notification, {"message" => msg}.to_json)
219
+
220
+ new_master.master!
221
+ @current_master = new_master
222
+ else
223
+ msg = "Redis master could not be switched, no slave available to become new master, promoting old master"
224
+ logger.error(msg)
225
+ beetle.publish(:system_notification, {"message" => msg}.to_json)
226
+ end
227
+
228
+ publish_master(current_master)
229
+ master_watcher.continue
230
+ end
231
+
232
+ def publish_master(master)
233
+ logger.info "Publishing reconfigure message with server '#{master.server}'"
234
+ beetle.publish(:reconfigure, payload_with_current_token({"server" => master.server}))
235
+ end
236
+
237
+ def configure_slaves(master)
238
+ (redis.masters-[master]).each do |r|
239
+ logger.info "Reconfiguring '#{r.server}' as a slave of '#{master.server}'"
240
+ r.slave_of!(master.host, master.port)
241
+ end
242
+ end
243
+
244
+ # Periodically checks a redis server for availability
245
+ class RedisWatcher #:nodoc:
246
+ include Logging
247
+
248
+ def initialize(configuration_server)
249
+ @configuration_server = configuration_server
250
+ @retries = 0
251
+ @paused = true
252
+ @master_retry_interval = configuration_server.config.redis_configuration_master_retry_interval
253
+ @master_retries = configuration_server.config.redis_configuration_master_retries
254
+ end
255
+
256
+ def pause
257
+ logger.info "Pause checking availability of redis servers"
258
+ @watch_timer.cancel if @watch_timer
259
+ @watch_timer = nil
260
+ @paused = true
261
+ end
262
+
263
+ def watch
264
+ @watch_timer ||=
265
+ begin
266
+ logger.info "Start watching redis servers every #{@master_retry_interval} seconds"
267
+ EventMachine::add_periodic_timer(@master_retry_interval) { check_availability }
268
+ end
269
+ @paused = false
270
+ end
271
+ alias continue watch
272
+
273
+ def paused?
274
+ @paused
275
+ end
276
+
277
+ private
278
+
279
+ def check_availability
280
+ @configuration_server.redis.refresh
281
+ if @configuration_server.master_available?
282
+ @configuration_server.master_available!
283
+ else
284
+ logger.warn "Redis master not available! (Retries left: #{@master_retries - (@retries + 1)})"
285
+ if (@retries+=1) >= @master_retries
286
+ @retries = 0
287
+ @configuration_server.master_unavailable!
288
+ end
289
+ end
290
+ end
291
+ end
292
+
293
+ # Dispatches messages from the queue to methods in RedisConfigurationServer
294
+ class MessageDispatcher < Beetle::Handler #:nodoc:
295
+ cattr_accessor :configuration_server
296
+ def process
297
+ @@configuration_server.__send__(message.header.routing_key, ActiveSupport::JSON.decode(message.data))
298
+ end
299
+ end
300
+ end
301
+ end