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
data/README.rdoc CHANGED
@@ -18,9 +18,9 @@ More information can be found on the {project website}[http://xing.github.com/be
18
18
  === Configuration
19
19
  # configure machines
20
20
 
21
- Beetle.config do |c|
21
+ Beetle.config do |config|
22
22
  config.servers = "broker1:5672, broker2:5672"
23
- config.redis_hosts = "redis1:6379, redis2:6379"
23
+ config.redis_server = "redis1:6379"
24
24
  end
25
25
 
26
26
  # instantiate a beetle client
@@ -41,7 +41,19 @@ More information can be found on the {project website}[http://xing.github.com/be
41
41
  === Subscribing
42
42
  b.listen
43
43
 
44
- :include: examples/README.rdoc
44
+ === Examples
45
+
46
+ Beetle ships with a number of {example scripts}[http://github.com/xing/beetle/tree/master/examples/].
47
+
48
+ The top level Rakefile comes with targets to start several RabbitMQ and redis instances
49
+ locally. Make sure the corresponding binaries are in your search path. Open four new shell
50
+ windows and execute the following commands:
51
+
52
+ rake rabbit:start1
53
+ rake rabbit:start2
54
+ rake redis:start1
55
+ rake redis:start2
56
+
45
57
 
46
58
  == Prerequisites
47
59
 
@@ -68,8 +80,9 @@ For development, you'll need
68
80
  == Authors
69
81
 
70
82
  {Stefan Kaes}[http://github.com/skaes],
71
- {Pascal Friederich}[http://github.com/paukul] and
72
- {Ali Jelveh}[http://github.com/dudemeister].
83
+ {Pascal Friederich}[http://github.com/paukul],
84
+ {Ali Jelveh}[http://github.com/dudemeister] and
85
+ {Sebastian Roebke}[http://github.com/boosty].
73
86
 
74
87
  You cand find out more about our work on our {dev blog}[http://devblog.xing.com].
75
88
 
@@ -77,6 +90,3 @@ Copyright (c) 2010 {XING AG}[http://www.xing.com/]
77
90
 
78
91
  Released under the MIT license. For full details see MIT-LICENSE included in this
79
92
  distribution.
80
-
81
-
82
-
data/beetle.gemspec CHANGED
@@ -1,127 +1,43 @@
1
- # Generated by jeweler
2
- # DO NOT EDIT THIS FILE DIRECTLY
3
- # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
4
- # -*- encoding: utf-8 -*-
5
-
6
1
  Gem::Specification.new do |s|
7
- s.name = %q{beetle}
8
- s.version = "0.1"
2
+ s.name = "beetle"
3
+ s.version = "0.2.1"
4
+
5
+ s.required_rubygems_version = ">= 1.3.1"
6
+ s.authors = ["Stefan Kaes", "Pascal Friederich", "Ali Jelveh", "Sebastian Roebke"]
7
+ s.date = Time.now.strftime('%Y-%m-%d')
8
+ s.default_executable = "beetle"
9
+ s.description = "A highly available, reliable messaging infrastructure"
10
+ s.summary = "High Availability AMQP Messaging with Redundant Queues"
11
+ s.email = "developers@xing.com"
12
+ s.executables = ["beetle"]
13
+ s.extra_rdoc_files = ["README.rdoc"]
14
+ s.files = Dir['{examples,ext,lib}/**/*.rb'] + %w(beetle.gemspec examples/README.rdoc)
15
+ s.extensions = 'ext/mkrf_conf.rb'
16
+ s.homepage = "http://xing.github.com/beetle/"
17
+ s.rdoc_options = ["--charset=UTF-8"]
18
+ s.require_paths = ["lib"]
19
+ s.rubygems_version = "1.3.7"
20
+ s.test_files = Dir['test/**/*.rb']
21
+
22
+ s.post_install_message = <<-INFO
23
+ *********************************************************************************************
9
24
 
10
- s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
- s.authors = ["Stefan Kaes", "Pascal Friederich", "Ali Jelveh"]
12
- s.date = %q{2010-04-14}
13
- s.description = %q{A highly available, reliable messaging infrastructure}
14
- s.email = %q{developers@xing.com}
15
- s.extra_rdoc_files = [
16
- "README.rdoc",
17
- "TODO"
18
- ]
19
- s.files = [
20
- ".gitignore",
21
- "MIT-LICENSE",
22
- "README.rdoc",
23
- "Rakefile",
24
- "TODO",
25
- "beetle.gemspec",
26
- "doc/redundant_queues.graffle",
27
- "etc/redis-master.conf",
28
- "etc/redis-slave.conf",
29
- "examples/README.rdoc",
30
- "examples/attempts.rb",
31
- "examples/handler_class.rb",
32
- "examples/handling_exceptions.rb",
33
- "examples/multiple_exchanges.rb",
34
- "examples/multiple_queues.rb",
35
- "examples/redis_failover.rb",
36
- "examples/redundant.rb",
37
- "examples/rpc.rb",
38
- "examples/simple.rb",
39
- "lib/beetle.rb",
40
- "lib/beetle/base.rb",
41
- "lib/beetle/client.rb",
42
- "lib/beetle/configuration.rb",
43
- "lib/beetle/deduplication_store.rb",
44
- "lib/beetle/handler.rb",
45
- "lib/beetle/message.rb",
46
- "lib/beetle/publisher.rb",
47
- "lib/beetle/r_c.rb",
48
- "lib/beetle/subscriber.rb",
49
- "script/start_rabbit",
50
- "snafu.rb",
51
- "test/beetle.yml",
52
- "test/beetle/base_test.rb",
53
- "test/beetle/bla.rb",
54
- "test/beetle/client_test.rb",
55
- "test/beetle/configuration_test.rb",
56
- "test/beetle/deduplication_store_test.rb",
57
- "test/beetle/handler_test.rb",
58
- "test/beetle/message_test.rb",
59
- "test/beetle/publisher_test.rb",
60
- "test/beetle/r_c_test.rb",
61
- "test/beetle/subscriber_test.rb",
62
- "test/beetle_test.rb",
63
- "test/test_helper.rb",
64
- "tmp/master/.gitignore",
65
- "tmp/slave/.gitignore"
66
- ]
67
- s.homepage = %q{http://xing.github.com/beetle/}
68
- s.rdoc_options = ["--charset=UTF-8"]
69
- s.require_paths = ["lib"]
70
- s.rubygems_version = %q{1.3.5}
71
- s.summary = %q{High Availability AMQP Messaging with Redundant Queues}
72
- s.test_files = [
73
- "test/beetle/base_test.rb",
74
- "test/beetle/bla.rb",
75
- "test/beetle/client_test.rb",
76
- "test/beetle/configuration_test.rb",
77
- "test/beetle/deduplication_store_test.rb",
78
- "test/beetle/handler_test.rb",
79
- "test/beetle/message_test.rb",
80
- "test/beetle/publisher_test.rb",
81
- "test/beetle/r_c_test.rb",
82
- "test/beetle/subscriber_test.rb",
83
- "test/beetle_test.rb",
84
- "test/test_helper.rb",
85
- "examples/attempts.rb",
86
- "examples/handler_class.rb",
87
- "examples/handling_exceptions.rb",
88
- "examples/multiple_exchanges.rb",
89
- "examples/multiple_queues.rb",
90
- "examples/redis_failover.rb",
91
- "examples/redundant.rb",
92
- "examples/rpc.rb",
93
- "examples/simple.rb"
94
- ]
25
+ If you're running a ruby version < 1.9 we silently installed the SystemTimer gem for you.
26
+ See: http://ph7spot.com/musings/system-timer
95
27
 
96
- if s.respond_to? :specification_version then
97
- current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
98
- s.specification_version = 3
28
+ *********************************************************************************************
29
+ INFO
99
30
 
100
- if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
101
- s.add_runtime_dependency(%q<uuid4r>, [">= 0.1.1"])
102
- s.add_runtime_dependency(%q<bunny>, [">= 0.6.0"])
103
- s.add_runtime_dependency(%q<redis>, [">= 0.1.2"])
104
- s.add_runtime_dependency(%q<amqp>, [">= 0.6.7"])
105
- s.add_runtime_dependency(%q<activesupport>, [">= 2.3.4"])
106
- s.add_development_dependency(%q<mocha>, [">= 0"])
107
- s.add_development_dependency(%q<rcov>, [">= 0"])
108
- else
109
- s.add_dependency(%q<uuid4r>, [">= 0.1.1"])
110
- s.add_dependency(%q<bunny>, [">= 0.6.0"])
111
- s.add_dependency(%q<redis>, [">= 0.1.2"])
112
- s.add_dependency(%q<amqp>, [">= 0.6.7"])
113
- s.add_dependency(%q<activesupport>, [">= 2.3.4"])
114
- s.add_dependency(%q<mocha>, [">= 0"])
115
- s.add_dependency(%q<rcov>, [">= 0"])
116
- end
117
- else
118
- s.add_dependency(%q<uuid4r>, [">= 0.1.1"])
119
- s.add_dependency(%q<bunny>, [">= 0.6.0"])
120
- s.add_dependency(%q<redis>, [">= 0.1.2"])
121
- s.add_dependency(%q<amqp>, [">= 0.6.7"])
122
- s.add_dependency(%q<activesupport>, [">= 2.3.4"])
123
- s.add_dependency(%q<mocha>, [">= 0"])
124
- s.add_dependency(%q<rcov>, [">= 0"])
125
- end
31
+ s.specification_version = 3
32
+ s.add_runtime_dependency("uuid4r", [">= 0.1.1"])
33
+ s.add_runtime_dependency("bunny", [">= 0.6.0"])
34
+ s.add_runtime_dependency("redis", [">= 2.0.3"])
35
+ s.add_runtime_dependency("amqp", [">= 0.6.7"])
36
+ s.add_runtime_dependency("activesupport", [">= 2.3.4"])
37
+ s.add_runtime_dependency("daemons", [">= 1.0.10"])
38
+ s.add_development_dependency("mocha", [">= 0"])
39
+ s.add_development_dependency("rcov", [">= 0"])
40
+ s.add_development_dependency("cucumber", [">= 0.7.2"])
41
+ s.add_development_dependency("daemon_controller", [">= 0"])
126
42
  end
127
43
 
data/bin/beetle ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require 'beetle/commands'
5
+ rescue LoadError
6
+ beetle_path = File.expand_path("../../lib", __FILE__)
7
+ $:.unshift(beetle_path)
8
+ require 'beetle/commands'
9
+ end
data/examples/README.rdoc CHANGED
@@ -10,5 +10,3 @@ windows and execute the following commands:
10
10
  rake rabbit:start2
11
11
  rake redis:start1
12
12
  rake redis:start2
13
-
14
- After running the redis_failover.rb script you will need to restart both redis servers.
data/examples/rpc.rb CHANGED
@@ -4,9 +4,10 @@ require File.expand_path(File.dirname(__FILE__)+"/../lib/beetle")
4
4
 
5
5
  # suppress debug messages
6
6
  Beetle.config.logger.level = Logger::DEBUG
7
-
7
+ Beetle.config.servers = "localhost:5672, localhost:5673"
8
8
  # instantiate a client
9
- client = Beetle::Client.new(:servers => "localhost:5672, localhost:5673")
9
+
10
+ client = Beetle::Client.new
10
11
 
11
12
  # register a durable queue named 'test'
12
13
  # this implicitly registers a durable topic exchange called 'test'
data/ext/mkrf_conf.rb ADDED
@@ -0,0 +1,19 @@
1
+ require 'rubygems'
2
+ require 'rubygems/command.rb'
3
+ require 'rubygems/dependency_installer.rb'
4
+ begin
5
+ Gem::Command.build_args = ARGV
6
+ rescue NoMethodError
7
+ end
8
+ inst = Gem::DependencyInstaller.new
9
+ begin
10
+ if RUBY_VERSION < "1.9"
11
+ inst.install "SystemTimer", ">= 1.2"
12
+ end
13
+ rescue
14
+ exit(1)
15
+ end
16
+
17
+ f = File.open(File.join(File.dirname(__FILE__), "Rakefile"), "w") # create dummy rakefile to indicate success
18
+ f.write("task :default\n")
19
+ f.close
data/lib/beetle.rb CHANGED
@@ -17,8 +17,6 @@ module Beetle
17
17
  class UnknownQueue < Error; end
18
18
  # raised when no redis master server can be found
19
19
  class NoRedisMaster < Error; end
20
- # raised when two redis master servers are found
21
- class TwoRedisMasters < Error; end
22
20
 
23
21
  # AMQP options for exchange creation
24
22
  EXCHANGE_CREATION_KEYS = [:auto_delete, :durable, :internal, :nowait, :passive]
@@ -37,6 +35,8 @@ module Beetle
37
35
  autoload File.basename(libfile)[/^(.*)\.rb$/, 1].classify, libfile
38
36
  end
39
37
 
38
+ require "#{lib_dir}/redis_ext"
39
+
40
40
  # returns the default configuration object and yields it if a block is given
41
41
  def self.config
42
42
  #:yields: config
data/lib/beetle/base.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  module Beetle
2
2
  # Abstract base class shared by Publisher and Subscriber
3
3
  class Base
4
+ include Logging
4
5
 
5
6
  attr_accessor :options, :servers, :server #:nodoc:
6
7
 
@@ -15,14 +16,6 @@ module Beetle
15
16
 
16
17
  private
17
18
 
18
- def logger
19
- self.class.logger
20
- end
21
-
22
- def self.logger
23
- Beetle.config.logger
24
- end
25
-
26
19
  def error(text)
27
20
  logger.error text
28
21
  raise Error.new(text)
data/lib/beetle/client.rb CHANGED
@@ -19,6 +19,8 @@ module Beetle
19
19
  # order, so that no message is lost if message producers are accidentally started before
20
20
  # the corresponding consumers.
21
21
  class Client
22
+ include Logging
23
+
22
24
  # the AMQP servers available for publishing
23
25
  attr_reader :servers
24
26
 
@@ -37,14 +39,18 @@ module Beetle
37
39
  # the deduplication store to use for this client
38
40
  attr_reader :deduplication_store
39
41
 
42
+ # accessor for the beetle configuration
43
+ attr_reader :config
44
+
40
45
  # create a fresh Client instance from a given configuration object
41
46
  def initialize(config = Beetle.config)
47
+ @config = config
42
48
  @servers = config.servers.split(/ *, */)
43
49
  @exchanges = {}
44
50
  @queues = {}
45
51
  @messages = {}
46
52
  @bindings = {}
47
- @deduplication_store = DeduplicationStore.new(config.redis_hosts, config.redis_db)
53
+ @deduplication_store = DeduplicationStore.new(config)
48
54
  end
49
55
 
50
56
  # register an exchange with the given _name_ and a set of _options_:
@@ -69,8 +75,8 @@ module Beetle
69
75
  def register_queue(name, options={})
70
76
  name = name.to_s
71
77
  raise ConfigurationError.new("queue #{name} already configured") if queues.include?(name)
72
- opts = {:exchange => name, :key => name}.merge!(options.symbolize_keys)
73
- opts.merge! :durable => true, :passive => false, :exclusive => false, :auto_delete => false, :amqp_name => name
78
+ opts = {:exchange => name, :key => name, :auto_delete => false, :amqp_name => name}.merge!(options.symbolize_keys)
79
+ opts.merge! :durable => true, :passive => false, :exclusive => false
74
80
  exchange = opts.delete(:exchange).to_s
75
81
  key = opts.delete(:key)
76
82
  queues[name] = opts
@@ -139,10 +145,10 @@ module Beetle
139
145
 
140
146
  # this is a convenience method to configure exchanges, queues, messages and handlers
141
147
  # with a common set of options. allows one to call all register methods without the
142
- # register_ prefix.
148
+ # register_ prefix. returns self.
143
149
  #
144
150
  # Example:
145
- # client.configure :exchange => :foobar do |config|
151
+ # client = Beetle.client.new.configure :exchange => :foobar do |config|
146
152
  # config.queue :q1, :key => "foo"
147
153
  # config.queue :q2, :key => "bar"
148
154
  # config.message :foo
@@ -152,6 +158,7 @@ module Beetle
152
158
  # end
153
159
  def configure(options={}) #:yields: config
154
160
  yield Configurator.new(self, options)
161
+ self
155
162
  end
156
163
 
157
164
  # publishes a message. the given options hash is merged with options given on message registration.
@@ -195,10 +202,10 @@ module Beetle
195
202
  publisher.stop
196
203
  end
197
204
 
198
- # traces all messages received on all queues. useful for debugging message flow.
199
- def trace(&block)
205
+ # traces messages without consuming them. useful for debugging message flow.
206
+ def trace(messages=self.messages.keys, &block)
200
207
  queues.each do |name, opts|
201
- opts.merge! :durable => false, :auto_delete => true, :amqp_name => queue_name_for_tracing(name)
208
+ opts.merge! :durable => false, :auto_delete => true, :amqp_name => queue_name_for_tracing(opts[:amqp_name])
202
209
  end
203
210
  register_handler(queues.keys) do |msg|
204
211
  puts "-----===== new message =====-----"
@@ -207,7 +214,7 @@ module Beetle
207
214
  puts "MSGID: #{msg.msg_id}"
208
215
  puts "DATA: #{msg.data}"
209
216
  end
210
- subscriber.listen(messages.keys, &block)
217
+ listen(messages, &block)
211
218
  end
212
219
 
213
220
  # evaluate the ruby files matching the given +glob+ pattern in the context of the client instance.
@@ -218,11 +225,6 @@ module Beetle
218
225
  end
219
226
  end
220
227
 
221
- # returns the configured Logger instance
222
- def logger
223
- @logger ||= Beetle.config.logger
224
- end
225
-
226
228
  private
227
229
 
228
230
  class Configurator #:nodoc:all
@@ -0,0 +1,30 @@
1
+ require 'rubygems'
2
+ require 'active_support'
3
+
4
+ module Beetle
5
+ module Commands
6
+ # invokes given command by instantiating an appropriate command class
7
+ def self.execute(command)
8
+ if commands.include? command
9
+ require File.expand_path("../commands/#{command}", __FILE__)
10
+ "Beetle::Commands::#{command.classify}".constantize.execute
11
+ else
12
+ # me no likez no frikin heredocs
13
+ puts "\nCommand #{command} not known\n" if command
14
+ puts "Available commands are:"
15
+ puts
16
+ commands.each {|c| puts "\t #{c}"}
17
+ puts
18
+ exit 1
19
+ end
20
+ end
21
+
22
+ private
23
+ def self.commands
24
+ commands_dir = File.expand_path('../commands', __FILE__)
25
+ Dir[commands_dir + '/*.rb'].map {|f| File.basename(f)[0..-4]}
26
+ end
27
+ end
28
+ end
29
+
30
+ Beetle::Commands.execute(ARGV.shift)
@@ -0,0 +1,73 @@
1
+ require 'optparse'
2
+ require 'daemons'
3
+ require 'beetle'
4
+
5
+ module Beetle
6
+ module Commands
7
+ # Command to start a RedisConfigurationClient daemon.
8
+ #
9
+ # Usage: beetle configuration_client [options] -- [client options]
10
+ #
11
+ # client options:
12
+ # --redis-master-file FILE Write redis master server string to FILE
13
+ # --id, --client-id ID Set unique client id (default is minastirith.local)
14
+ # --amqp-servers LIST AMQP server list (e.g. 192.168.0.1:5672,192.168.0.2:5672)
15
+ # --config-file PATH Path to an external yaml config file
16
+ # --pid-dir DIR Write pid and log to DIR
17
+ # -v, --verbose Set log level to DEBUG
18
+ # -h, --help Show this message
19
+ #
20
+ class ConfigurationClient
21
+ # parses command line options and starts Beetle::RedisConfigurationClient as a daemon
22
+ def self.execute
23
+ command, controller_options, app_options = Daemons::Controller.split_argv(ARGV)
24
+
25
+ opts = OptionParser.new
26
+ opts.banner = "Usage: beetle configuration_client #{command} [options] -- [client options]"
27
+ opts.separator ""
28
+ opts.separator "client options:"
29
+
30
+ opts.on("--redis-master-file FILE", String, "Write redis master server string to FILE") do |val|
31
+ Beetle.config.redis_server = val
32
+ end
33
+
34
+ client_id = nil
35
+ opts.on("--id ID", "--client-id ID", String, "Set unique client id (default is #{RedisConfigurationClient.new.id})") do |val|
36
+ client_id = val
37
+ end
38
+
39
+ opts.on("--amqp-servers LIST", String, "AMQP server list (e.g. 192.168.0.1:5672,192.168.0.2:5672)") do |val|
40
+ Beetle.config.servers = val
41
+ end
42
+
43
+ opts.on("--config-file PATH", String, "Path to an external yaml config file") do |val|
44
+ Beetle.config.config_file = val
45
+ end
46
+
47
+ dir_mode = nil
48
+ dir = nil
49
+ opts.on("--pid-dir DIR", String, "Write pid and log to DIR") do |val|
50
+ dir_mode = :normal
51
+ dir = val
52
+ end
53
+
54
+ opts.on("-v", "--verbose", "Set log level to DEBUG") do |val|
55
+ Beetle.config.logger.level = Logger::DEBUG
56
+ end
57
+
58
+ opts.on_tail("-h", "--help", "Show this message") do
59
+ puts opts
60
+ exit
61
+ end
62
+
63
+ opts.parse!(app_options)
64
+
65
+ Daemons.run_proc("redis_configuration_client", :multiple => true, :log_output => true, :dir_mode => dir_mode, :dir => dir) do
66
+ client = Beetle::RedisConfigurationClient.new
67
+ client.id = client_id if client_id
68
+ client.start
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end