beetle 0.1 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,85 @@
1
+ require 'optparse'
2
+ require 'daemons'
3
+ require 'beetle'
4
+
5
+ module Beetle
6
+ module Commands
7
+ # Command to start a RedisConfigurationServer daemon.
8
+ #
9
+ # Usage: beetle configuration_server [options] -- [server options]
10
+ #
11
+ # server options:
12
+ # --redis-servers LIST Required for start command (e.g. 192.168.0.1:6379,192.168.0.2:6379)
13
+ # --client-ids LIST Clients that have to acknowledge on master switch (e.g. client-id1,client-id2)
14
+ # --redis-master-file FILE Write redis master server string to FILE
15
+ # --redis-retry-interval SEC Number of seconds to wait between master checks
16
+ # --amqp-servers LIST AMQP server list (e.g. 192.168.0.1:5672,192.168.0.2:5672)
17
+ # --config-file PATH Path to an external yaml config file
18
+ # --pid-dir DIR Write pid and log to DIR
19
+ # -v, --verbose
20
+ # -h, --help Show this message
21
+ #
22
+ class ConfigurationServer
23
+ # parses command line options and starts Beetle::RedisConfigurationServer as a daemon
24
+ def self.execute
25
+ command, controller_options, app_options = Daemons::Controller.split_argv(ARGV)
26
+
27
+ opts = OptionParser.new
28
+ opts.banner = "Usage: beetle configuration_server #{command} [options] -- [server options]"
29
+ opts.separator ""
30
+ opts.separator "server options:"
31
+
32
+ opts.on("--redis-servers LIST", Array, "Required for start command (e.g. 192.168.0.1:6379,192.168.0.2:6379)") do |val|
33
+ Beetle.config.redis_servers = val.join(",")
34
+ end
35
+
36
+ opts.on("--client-ids LIST", "Clients that have to acknowledge on master switch (e.g. client-id1,client-id2)") do |val|
37
+ Beetle.config.redis_configuration_client_ids = val
38
+ end
39
+
40
+ opts.on("--redis-master-file FILE", String, "Write redis master server string to FILE") do |val|
41
+ Beetle.config.redis_server = val
42
+ end
43
+
44
+ opts.on("--redis-retry-interval SEC", Integer, "Number of seconds to wait between master checks") do |val|
45
+ Beetle.config.redis_configuration_master_retry_interval = val
46
+ end
47
+
48
+ opts.on("--amqp-servers LIST", String, "AMQP server list (e.g. 192.168.0.1:5672,192.168.0.2:5672)") do |val|
49
+ Beetle.config.servers = val
50
+ end
51
+
52
+ opts.on("--config-file PATH", String, "Path to an external yaml config file") do |val|
53
+ Beetle.config.config_file = val
54
+ end
55
+
56
+ dir_mode = nil
57
+ dir = nil
58
+ opts.on("--pid-dir DIR", String, "Write pid and log to DIR") do |val|
59
+ dir_mode = :normal
60
+ dir = val
61
+ end
62
+
63
+ opts.on("-v", "--verbose") do |val|
64
+ Beetle.config.logger.level = Logger::DEBUG
65
+ end
66
+
67
+ opts.on_tail("-h", "--help", "Show this message") do
68
+ puts opts
69
+ exit
70
+ end
71
+
72
+ opts.parse!(app_options)
73
+
74
+ if command =~ /start|run/ && Beetle.config.redis_servers.blank?
75
+ puts opts
76
+ exit
77
+ end
78
+
79
+ Daemons.run_proc("redis_configuration_server", :log_output => true, :dir_mode => dir_mode, :dir => dir) do
80
+ Beetle::RedisConfigurationServer.new.start
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -1,31 +1,94 @@
1
+ require 'erb'
2
+
1
3
  module Beetle
2
4
  class Configuration
5
+ # system name (used for redis cluster partitioning) (defaults to <tt>system</tt>)
6
+ attr_accessor :system_name
3
7
  # default logger (defaults to <tt>Logger.new(STDOUT)</tt>)
4
8
  attr_accessor :logger
5
9
  # number of seconds after which keys are removed form the message deduplication store (defaults to <tt>3.days</tt>)
6
10
  attr_accessor :gc_threshold
7
- # the machines where the deduplication store lives (defaults to <tt>"localhost:6379"</tt>)
8
- attr_accessor :redis_hosts
11
+ # the redis server to use for deduplication
12
+ # either a string like <tt>"localhost:6379"</tt> (default) or a file that contains the string.
13
+ # use a file if you are using a beetle configuration_client process to update it for automatic redis failover.
14
+ attr_accessor :redis_server
15
+ # comma separated list of redis servers available for master/slave switching
16
+ # e.g. "192.168.1.2:6379,192.168.1.3:6379"
17
+ attr_accessor :redis_servers
9
18
  # redis database number to use for the message deduplication store (defaults to <tt>4</tt>)
10
19
  attr_accessor :redis_db
20
+
21
+ # how long we should repeatedly retry a redis operation before giving up, with a one
22
+ # second sleep between retries (defaults to <tt>180.seconds</tt>). this value needs to be
23
+ # somewehere between the maximum time it takes to auto-switch redis and the smallest
24
+ # handler timeout.
25
+ attr_accessor :redis_failover_timeout
26
+
27
+ ## redis configuration server options
28
+ # how often should the redis configuration server try to reach the redis master before nominating a new one (defaults to <tt>3</tt>)
29
+ attr_accessor :redis_configuration_master_retries
30
+ # number of seconds to wait between retries (defaults to <tt>10</tt>)
31
+ attr_accessor :redis_configuration_master_retry_interval
32
+ # number of seconds the redis configuration server waits for answers from clients (defaults to <tt>5</tt>)
33
+ attr_accessor :redis_configuration_client_timeout
34
+ # the redis configuration client ids living on the worker machines taking part in the redis failover, separated by comma (defaults to <tt>""</tt>)
35
+ attr_accessor :redis_configuration_client_ids
36
+
11
37
  # list of amqp servers to use (defaults to <tt>"localhost:5672"</tt>)
12
38
  attr_accessor :servers
13
- # the virtual host to use on the AMQP servers
39
+ # the virtual host to use on the AMQP servers (defaults to <tt>"/"</tt>)
14
40
  attr_accessor :vhost
15
- # the AMQP user to use when connecting to the AMQP servers
41
+ # the AMQP user to use when connecting to the AMQP servers (defaults to <tt>"guest"</tt>)
16
42
  attr_accessor :user
17
- # the password to use when connectiong to the AMQP servers
43
+ # the password to use when connectiong to the AMQP servers (defaults to <tt>"guest"</tt>)
18
44
  attr_accessor :password
19
45
 
46
+ # external config file (defaults to <tt>no file</tt>)
47
+ attr_reader :config_file
48
+
20
49
  def initialize #:nodoc:
21
- self.logger = Logger.new(STDOUT)
50
+ self.system_name = "system"
51
+
52
+ self.logger = begin
53
+ logger = Logger.new(STDOUT)
54
+ logger.formatter = Logger::Formatter.new
55
+ logger.level = Logger::INFO
56
+ logger.datetime_format = "%Y-%m-%d %H:%M:%S"
57
+ logger
58
+ end
59
+
22
60
  self.gc_threshold = 3.days
23
- self.redis_hosts = "localhost:6379"
61
+ self.redis_server = "localhost:6379"
62
+ self.redis_servers = ""
24
63
  self.redis_db = 4
64
+ self.redis_failover_timeout = 180.seconds
65
+
66
+ self.redis_configuration_master_retries = 3
67
+ self.redis_configuration_master_retry_interval = 10.seconds
68
+ self.redis_configuration_client_timeout = 5.seconds
69
+ self.redis_configuration_client_ids = ""
70
+
25
71
  self.servers = "localhost:5672"
26
72
  self.vhost = "/"
27
73
  self.user = "guest"
28
74
  self.password = "guest"
29
75
  end
76
+
77
+ # setting the external config file will load it on assignment
78
+ def config_file=(file_name) #:nodoc:
79
+ @config_file = file_name
80
+ load_config
81
+ end
82
+
83
+ private
84
+ def load_config
85
+ hash = YAML::load(ERB.new(IO.read(config_file)).result)
86
+ hash.each do |key, value|
87
+ send("#{key}=", value)
88
+ end
89
+ rescue Exception
90
+ logger.error "Error loading beetle config file '#{config_file}': #{$!}"
91
+ raise
92
+ end
30
93
  end
31
94
  end
@@ -11,15 +11,23 @@ module Beetle
11
11
  #
12
12
  # It also provides a method to garbage collect keys for expired messages.
13
13
  class DeduplicationStore
14
- # creates a new deduplication store
15
- def initialize(hosts = "localhost:6379", db = 4)
16
- @hosts = hosts
17
- @db = db
14
+ include Logging
15
+
16
+ def initialize(config = Beetle.config)
17
+ @config = config
18
+ @current_master = nil
19
+ @last_time_master_file_changed = nil
18
20
  end
19
21
 
20
22
  # get the Redis instance
21
23
  def redis
22
- @redis ||= find_redis_master
24
+ redis_master_source = @config.redis_server =~ /^\S+\:\d+$/ ? "server_string" : "master_file"
25
+ _eigenclass_.class_eval <<-EVALS, __FILE__, __LINE__
26
+ def redis
27
+ redis_master_from_#{redis_master_source}
28
+ end
29
+ EVALS
30
+ redis
23
31
  end
24
32
 
25
33
  # list of key suffixes to use for storing values in Redis.
@@ -43,7 +51,7 @@ module Beetle
43
51
  # garbage collect keys in Redis (always assume the worst!)
44
52
  def garbage_collect_keys(now = Time.now.to_i)
45
53
  keys = redis.keys("msgid:*:expires")
46
- threshold = now + Beetle.config.gc_threshold
54
+ threshold = now + @config.gc_threshold
47
55
  keys.each do |key|
48
56
  expires_at = redis.get key
49
57
  if expires_at && expires_at.to_i < threshold
@@ -53,20 +61,20 @@ module Beetle
53
61
  end
54
62
  end
55
63
 
56
- # unconditionally store a key <tt>value></tt> with given <tt>suffix</tt> for given <tt>msg_id</tt>.
64
+ # unconditionally store a (key,value) pair with given <tt>suffix</tt> for given <tt>msg_id</tt>.
57
65
  def set(msg_id, suffix, value)
58
66
  with_failover { redis.set(key(msg_id, suffix), value) }
59
67
  end
60
68
 
61
- # store a key <tt>value></tt> with given <tt>suffix</tt> for given <tt>msg_id</tt> if it doesn't exists yet.
69
+ # store a (key,value) pair with given <tt>suffix</tt> for given <tt>msg_id</tt> if it doesn't exists yet.
62
70
  def setnx(msg_id, suffix, value)
63
71
  with_failover { redis.setnx(key(msg_id, suffix), value) }
64
72
  end
65
73
 
66
74
  # store some key/value pairs if none of the given keys exist.
67
75
  def msetnx(msg_id, values)
68
- values = values.inject({}){|h,(k,v)| h[key(msg_id, k)] = v; h}
69
- with_failover { redis.msetnx(values) }
76
+ values = values.inject([]){|a,(k,v)| a.concat([key(msg_id, k), v])}
77
+ with_failover { redis.msetnx(*values) }
70
78
  end
71
79
 
72
80
  # increment counter for key with given <tt>suffix</tt> for given <tt>msg_id</tt>. returns an integer.
@@ -86,7 +94,7 @@ module Beetle
86
94
 
87
95
  # delete all keys associated with the given <tt>msg_id</tt>.
88
96
  def del_keys(msg_id)
89
- with_failover { redis.del(keys(msg_id)) }
97
+ with_failover { redis.del(*keys(msg_id)) }
90
98
  end
91
99
 
92
100
  # check whether key with given suffix exists for a given <tt>msg_id</tt>.
@@ -101,16 +109,15 @@ module Beetle
101
109
 
102
110
  # performs redis operations by yielding a passed in block, waiting for a new master to
103
111
  # show up on the network if the operation throws an exception. if a new master doesn't
104
- # appear after 120 seconds, we raise an exception.
112
+ # appear after the configured timeout interval, we raise an exception.
105
113
  def with_failover #:nodoc:
106
- tries = 0
114
+ end_time = Time.now.to_i + @config.redis_failover_timeout.to_i
107
115
  begin
108
116
  yield
109
117
  rescue Exception => e
110
118
  Beetle::reraise_expectation_errors!
111
- logger.error "Beetle: redis connection error '#{e}'"
112
- if (tries+=1) < 120
113
- @redis = nil
119
+ logger.error "Beetle: redis connection error #{e} #{@config.redis_server} (#{e.backtrace[0]})"
120
+ if Time.now.to_i < end_time
114
121
  sleep 1
115
122
  logger.info "Beetle: retrying redis operation"
116
123
  retry
@@ -120,33 +127,38 @@ module Beetle
120
127
  end
121
128
  end
122
129
 
123
- # find the master redis instance
124
- def find_redis_master
125
- masters = []
126
- redis_instances.each do |redis|
127
- begin
128
- masters << redis if redis.info[:role] == "master"
129
- rescue Exception => e
130
- logger.error "Beetle: could not determine status of redis instance #{redis.server}"
131
- end
132
- end
133
- raise NoRedisMaster.new("unable to determine a new master redis instance") if masters.empty?
134
- raise TwoRedisMasters.new("more than one redis master instances") if masters.size > 1
135
- logger.info "Beetle: configured new redis master #{masters.first.server}"
136
- masters.first
130
+ # set current redis master instance (as specified in the Beetle::Configuration)
131
+ def redis_master_from_server_string
132
+ @current_master ||= Redis.from_server_string(@config.redis_server, :db => @config.redis_db)
137
133
  end
138
134
 
139
- # returns the list of redis instances
140
- def redis_instances
141
- @redis_instances ||= @hosts.split(/ *, */).map{|s| s.split(':')}.map do |host, port|
142
- Redis.new(:host => host, :port => port, :db => @db)
143
- end
135
+ # set current redis master from master file
136
+ def redis_master_from_master_file
137
+ set_current_redis_master_from_master_file if redis_master_file_changed?
138
+ @current_master
139
+ rescue Errno::ENOENT
140
+ nil
144
141
  end
145
142
 
146
- # returns the configured logger
147
- def logger
148
- Beetle.config.logger
143
+ # redis master file changed outside the running process?
144
+ def redis_master_file_changed?
145
+ @last_time_master_file_changed != File.mtime(@config.redis_server)
149
146
  end
150
147
 
148
+ # set current redis master from server:port string contained in the redis master file
149
+ def set_current_redis_master_from_master_file
150
+ @last_time_master_file_changed = File.mtime(@config.redis_server)
151
+ server_string = read_master_file
152
+ @current_master = !server_string.blank? ? Redis.from_server_string(server_string, :db => @config.redis_db) : nil
153
+ end
154
+
155
+ # server:port string from the redis master file
156
+ def read_master_file
157
+ File.read(@config.redis_server).chomp
158
+ end
159
+
160
+ def _eigenclass_ #:nodoc:
161
+ class << self; self; end
162
+ end
151
163
  end
152
164
  end
@@ -6,6 +6,8 @@ module Beetle
6
6
  # Most applications will define Handler subclasses and override the process, error and
7
7
  # failure methods.
8
8
  class Handler
9
+ include Logging
10
+
9
11
  # the Message instance which caused the handler to be created
10
12
  attr_reader :message
11
13
 
@@ -81,11 +83,6 @@ module Beetle
81
83
  logger.error "Beetle: handler has finally failed"
82
84
  end
83
85
 
84
- # returns the configured Beetle logger
85
- def logger
86
- Beetle.config.logger
87
- end
88
-
89
86
  # returns the configured Beetle logger
90
87
  def self.logger
91
88
  Beetle.config.logger
@@ -0,0 +1,7 @@
1
+ module Beetle
2
+ module Logging
3
+ def logger
4
+ Beetle.config.logger
5
+ end
6
+ end
7
+ end
@@ -6,6 +6,8 @@ module Beetle
6
6
  # should retry executing the message handler after a handler has crashed (or forcefully
7
7
  # aborted).
8
8
  class Message
9
+ include Logging
10
+
9
11
  # current message format version
10
12
  FORMAT_VERSION = 1
11
13
  # flag for encoding redundant messages
@@ -14,7 +16,7 @@ module Beetle
14
16
  DEFAULT_TTL = 1.day
15
17
  # forcefully abort a running handler after this many seconds.
16
18
  # can be overriden when registering a handler.
17
- DEFAULT_HANDLER_TIMEOUT = 300.seconds
19
+ DEFAULT_HANDLER_TIMEOUT = 600.seconds
18
20
  # how many times we should try to run a handler before giving up
19
21
  DEFAULT_HANDLER_EXECUTION_ATTEMPTS = 1
20
22
  # how many seconds we should wait before retrying handler execution
@@ -77,6 +79,8 @@ module Beetle
77
79
  @format_version = headers[:format_version].to_i
78
80
  @flags = headers[:flags].to_i
79
81
  @expires_at = headers[:expires_at].to_i
82
+ rescue Exception => @exception
83
+ logger.error "Could not decode message. #{self.inspect}"
80
84
  end
81
85
 
82
86
  # build hash with options for the publisher
@@ -193,7 +197,7 @@ module Beetle
193
197
  # have we already seen this message? if not, set the status to "incomplete" and store
194
198
  # the message exipration timestamp in the deduplication store.
195
199
  def key_exists?
196
- old_message = 0 == @store.msetnx(msg_id, :status =>"incomplete", :expires => @expires_at)
200
+ old_message = 0 == @store.msetnx(msg_id, :status =>"incomplete", :expires => @expires_at, :timeout => now + timeout)
197
201
  if old_message
198
202
  logger.debug "Beetle: received duplicate message: #{msg_id} on queue: #{@queue}"
199
203
  end
@@ -236,7 +240,10 @@ module Beetle
236
240
  private
237
241
 
238
242
  def process_internal(handler)
239
- if expired?
243
+ if @exception
244
+ ack!
245
+ RC::DecodingError
246
+ elsif expired?
240
247
  logger.warn "Beetle: ignored expired message (#{msg_id})!"
241
248
  ack!
242
249
  RC::Ancient
@@ -244,7 +251,6 @@ module Beetle
244
251
  ack!
245
252
  run_handler(handler) == RC::HandlerCrash ? RC::AttemptsLimitReached : RC::OK
246
253
  elsif !key_exists?
247
- set_timeout!
248
254
  run_handler!(handler)
249
255
  elsif completed?
250
256
  ack!
@@ -273,7 +279,7 @@ module Beetle
273
279
  end
274
280
 
275
281
  def run_handler(handler)
276
- Timeout::timeout(@timeout) { @handler_result = handler.call(self) }
282
+ Redis::Timer.timeout(@timeout.to_f) { @handler_result = handler.call(self) }
277
283
  RC::OK
278
284
  rescue Exception => @exception
279
285
  Beetle::reraise_expectation_errors!
@@ -313,14 +319,6 @@ module Beetle
313
319
  end
314
320
  end
315
321
 
316
- def logger
317
- @logger ||= self.class.logger
318
- end
319
-
320
- def self.logger
321
- Beetle.config.logger
322
- end
323
-
324
322
  # ack the message for rabbit. deletes all keys associated with this message in the
325
323
  # deduplication store if we are sure this is the last message with the given msg_id.
326
324
  def ack!
@@ -132,11 +132,16 @@ module Beetle
132
132
  b
133
133
  end
134
134
 
135
+ # retry dead servers after ignoring them for 10.seconds
136
+ # if all servers are dead, retry the one which has been dead for the longest time
135
137
  def recycle_dead_servers
136
138
  recycle = []
137
139
  @dead_servers.each do |s, dead_since|
138
140
  recycle << s if dead_since < 10.seconds.ago
139
141
  end
142
+ if recycle.empty? && @servers.empty?
143
+ recycle << @dead_servers.keys.sort_by{|k| @dead_servers[k]}.first
144
+ end
140
145
  @servers.concat recycle
141
146
  recycle.each {|s| @dead_servers.delete(s)}
142
147
  end
@@ -149,8 +154,11 @@ module Beetle
149
154
  end
150
155
 
151
156
  def select_next_server
152
- return logger.error("Beetle: message could not be delivered - no server available") && 0 if @servers.empty?
153
- set_current_server(@servers[((@servers.index(@server) || 0)+1) % @servers.size])
157
+ if @servers.empty?
158
+ logger.error("Beetle: no server available")
159
+ else
160
+ set_current_server(@servers[((@servers.index(@server) || 0)+1) % @servers.size])
161
+ end
154
162
  end
155
163
 
156
164
  def create_exchange!(name, opts)
@@ -165,9 +173,9 @@ module Beetle
165
173
 
166
174
  # TODO: Refactor, fethch the keys and stuff itself
167
175
  def bind_queue!(queue_name, creation_keys, exchange_name, binding_keys)
168
- logger.debug("Creating queue with opts: #{creation_keys.inspect}")
176
+ logger.debug("Beetle: creating queue with opts: #{creation_keys.inspect}")
169
177
  queue = bunny.queue(queue_name, creation_keys)
170
- logger.debug("Binding queue #{queue_name} to #{exchange_name} with opts: #{binding_keys.inspect}")
178
+ logger.debug("Beetle: binding queue #{queue_name} to #{exchange_name} with opts: #{binding_keys.inspect}")
171
179
  queue.bind(exchange(exchange_name), binding_keys)
172
180
  queue
173
181
  end