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
@@ -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