br-nanite 0.3.0

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.
@@ -0,0 +1,163 @@
1
+ module Nanite
2
+ class Agent
3
+ include AMQPHelper
4
+ include FileStreaming
5
+ include ConsoleHelper
6
+ include DaemonizeHelper
7
+
8
+ attr_reader :identity, :log, :options, :serializer, :dispatcher, :registry, :amq
9
+ attr_accessor :status_proc
10
+
11
+ DEFAULT_OPTIONS = COMMON_DEFAULT_OPTIONS.merge({:user => 'nanite', :ping_time => 15,
12
+ :default_services => []}) unless defined?(DEFAULT_OPTIONS)
13
+
14
+ # Initializes a new agent and establishes AMQP connection.
15
+ # This must be used inside EM.run block or if EventMachine reactor
16
+ # is already started, for instance, by a Thin server that your Merb/Rails
17
+ # application runs on.
18
+ #
19
+ # Agent options:
20
+ #
21
+ # identity : identity of this agent, may be any string
22
+ #
23
+ # status_proc : a callable object that returns agent load as a string,
24
+ # defaults to load averages string extracted from `uptime`
25
+ # format : format to use for packets serialization. One of the three:
26
+ # :marshall, :json, or :yaml. Defaults to
27
+ # Ruby's Marshall format. For interoperability with
28
+ # AMQP clients implemented in other languages, use JSON.
29
+ #
30
+ # Note that Nanite uses JSON gem,
31
+ # and ActiveSupport's JSON encoder may cause clashes
32
+ # if ActiveSupport is loaded after JSON gem.
33
+ #
34
+ # root : application root for this agent, defaults to Dir.pwd
35
+ #
36
+ # log_dir : path to directory where agent stores it's log file
37
+ # if not given, app_root is used.
38
+ #
39
+ # file_root : path to directory to files this agent provides
40
+ # defaults to app_root/files
41
+ #
42
+ # ping_time : time interval in seconds between two subsequent heartbeat messages
43
+ # this agent broadcasts. Default value is 15.
44
+ #
45
+ # console : true tells Nanite to start interactive console
46
+ #
47
+ # daemonize : true tells Nanite to daemonize
48
+ #
49
+ # services : list of services provided by this agent, by default
50
+ # all methods exposed by actors are listed
51
+ #
52
+ # Connection options:
53
+ #
54
+ # vhost : AMQP broker vhost that should be used
55
+ #
56
+ # user : AMQP broker user
57
+ #
58
+ # pass : AMQP broker password
59
+ #
60
+ # host : host AMQP broker (or node of interest) runs on,
61
+ # defaults to 0.0.0.0
62
+ #
63
+ # port : port AMQP broker (or node of interest) runs on,
64
+ # this defaults to 5672, port used by some widely
65
+ # used AMQP brokers (RabbitMQ and ZeroMQ)
66
+ #
67
+ # On start Nanite reads config.yml, so it is common to specify
68
+ # options in the YAML file. However, when both Ruby code options
69
+ # and YAML file specify option, Ruby code options take precedence.
70
+ def self.start(options = {})
71
+ new(options)
72
+ end
73
+
74
+ def initialize(opts)
75
+ set_configuration(opts)
76
+ @log = Log.new(@options, @identity)
77
+ @serializer = Serializer.new(@options[:format])
78
+ @status_proc = lambda { parse_uptime(`uptime`) rescue 'no status' }
79
+ daemonize if @options[:daemonize]
80
+ @amq = start_amqp(@options)
81
+ @registry = ActorRegistry.new(@log)
82
+ @dispatcher = Dispatcher.new(@amq, @registry, @serializer, @identity, @log, @options)
83
+ load_actors
84
+ setup_queue
85
+ advertise_services
86
+ setup_heartbeat
87
+ start_console if @options[:console] && !@options[:daemonize]
88
+ end
89
+
90
+ def register(actor, prefix = nil)
91
+ registry.register(actor, prefix)
92
+ end
93
+
94
+ protected
95
+
96
+ def set_configuration(opts)
97
+ @options = DEFAULT_OPTIONS.clone
98
+ root = opts[:root] || @options[:root]
99
+ custom_config = if root
100
+ file = File.expand_path(File.join(root, 'config.yml'))
101
+ File.exists?(file) ? (YAML.load(IO.read(file)) || {}) : {}
102
+ else
103
+ {}
104
+ end
105
+ opts.delete(:identity) unless opts[:identity]
106
+ @options.update(custom_config.merge(opts))
107
+ @options[:file_root] ||= File.join(@options[:root], 'files')
108
+ return @identity = "nanite-#{@options[:identity]}" if @options[:identity]
109
+ token = Identity.generate
110
+ @identity = "nanite-#{token}"
111
+ File.open(File.expand_path(File.join(@options[:root], 'config.yml')), 'w') do |fd|
112
+ fd.write(YAML.dump(custom_config.merge(:identity => token)))
113
+ end
114
+ end
115
+
116
+ def load_actors
117
+ return unless options[:root]
118
+ Dir["#{options[:root]}/actors/*.rb"].each do |actor|
119
+ log.info("loading actor: #{actor}")
120
+ require actor
121
+ end
122
+ init_path = File.join(options[:root], 'init.rb')
123
+ instance_eval(File.read(init_path), init_path) if File.exist?(init_path)
124
+ end
125
+
126
+ def receive(packet)
127
+ packet = serializer.load(packet)
128
+ case packet
129
+ when Advertise
130
+ log.debug("handling Advertise: #{packet}")
131
+ advertise_services
132
+ when Request, Push
133
+ log.debug("handling Request: #{packet}")
134
+ dispatcher.dispatch(packet)
135
+ end
136
+ end
137
+
138
+ def setup_queue
139
+ amq.queue(identity, :durable => true).subscribe(:ack => true) do |info, msg|
140
+ info.ack
141
+ receive(msg)
142
+ end
143
+ end
144
+
145
+ def setup_heartbeat
146
+ EM.add_periodic_timer(options[:ping_time]) do
147
+ amq.fanout('heartbeat', :no_declare => options[:secure]).publish(serializer.dump(Ping.new(identity, status_proc.call)))
148
+ end
149
+ end
150
+
151
+ def advertise_services
152
+ log.debug("advertise_services: #{registry.services.inspect}")
153
+ amq.fanout('registration', :no_declare => options[:secure]).publish(serializer.dump(Register.new(identity, registry.services, status_proc.call)))
154
+ end
155
+
156
+ def parse_uptime(up)
157
+ if up =~ /load averages?: (.*)/
158
+ a,b,c = $1.split(/\s+|,\s+/)
159
+ (a.to_f + b.to_f + c.to_f) / 3
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,47 @@
1
+ class MQ
2
+ class Queue
3
+ # Asks the broker to redeliver all unacknowledged messages on a
4
+ # specifieid channel. Zero or more messages may be redelivered.
5
+ #
6
+ # * requeue (default false)
7
+ # If this parameter is false, the message will be redelivered to the original recipient.
8
+ # If this flag is true, the server will attempt to requeue the message, potentially then
9
+ # delivering it to an alternative subscriber.
10
+ #
11
+ def recover requeue = false
12
+ @mq.callback{
13
+ @mq.send Protocol::Basic::Recover.new({ :requeue => requeue })
14
+ }
15
+ self
16
+ end
17
+ end
18
+ end
19
+
20
+ # monkey patch to the amqp gem that adds :no_declare => true option for new
21
+ # Exchange objects. This allows us to send messeages to exchanges that are
22
+ # declared by the mappers and that we have no configuration priviledges on.
23
+ # temporary until we get this into amqp proper
24
+ MQ::Exchange.class_eval do
25
+ def initialize mq, type, name, opts = {}
26
+ @mq = mq
27
+ @type, @name, @opts = type, name, opts
28
+ @mq.exchanges[@name = name] ||= self
29
+ @key = opts[:key]
30
+
31
+ @mq.callback{
32
+ @mq.send AMQP::Protocol::Exchange::Declare.new({ :exchange => name,
33
+ :type => type,
34
+ :nowait => true }.merge(opts))
35
+ } unless name == "amq.#{type}" or name == '' or opts[:no_declare]
36
+ end
37
+ end
38
+
39
+ module Nanite
40
+ module AMQPHelper
41
+ def start_amqp(options)
42
+ connection = AMQP.connect(:user => options[:user], :pass => options[:pass], :vhost => options[:vhost],
43
+ :host => options[:host], :port => (options[:port] || ::AMQP::PORT).to_i, :insist => options[:insist] || false)
44
+ MQ.new(connection)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,110 @@
1
+ module Nanite
2
+ class Cluster
3
+ attr_reader :agent_timeout, :nanites, :reaper, :log, :serializer, :identity, :amq
4
+
5
+ def initialize(amq, agent_timeout, identity, log, serializer)
6
+ @amq = amq
7
+ @agent_timeout = agent_timeout
8
+ @identity = identity
9
+ @serializer = serializer
10
+ @nanites = {}
11
+ @reaper = Reaper.new(agent_timeout)
12
+ @log = log
13
+ setup_queues
14
+ end
15
+
16
+ # determine which nanites should receive the given request
17
+ def targets_for(request)
18
+ return [request.target] if request.target
19
+ __send__(request.selector, request.type).collect {|name, state| name }
20
+ end
21
+
22
+ # adds nanite to nanites map: key is nanite's identity
23
+ # and value is a services/status pair implemented
24
+ # as a hash
25
+ def register(reg)
26
+ nanites[reg.identity] = { :services => reg.services, :status => reg.status }
27
+ reaper.timeout(reg.identity, agent_timeout + 1) { nanites.delete(reg.identity) }
28
+ log.info("registered: #{reg.identity}, #{nanites[reg.identity]}")
29
+ end
30
+
31
+ def route(request, targets)
32
+ EM.next_tick { targets.map { |target| publish(request, target) } }
33
+ end
34
+
35
+ def publish(request, target)
36
+ amq.queue(target).publish(serializer.dump(request), :persistent => request.persistent)
37
+ end
38
+
39
+ protected
40
+
41
+ # updates nanite information (last ping timestamps, status)
42
+ # when heartbeat message is received
43
+ def handle_ping(ping)
44
+ if nanite = nanites[ping.identity]
45
+ nanite[:status] = ping.status
46
+ reaper.reset(ping.identity)
47
+ else
48
+ amq.queue(ping.identity).publish(serializer.dump(Advertise.new))
49
+ end
50
+ end
51
+
52
+ # returns least loaded nanite that provides given service
53
+ def least_loaded(service)
54
+ candidates = nanites_providing(service)
55
+ return [] if candidates.empty?
56
+
57
+ [candidates.min { |a,b| a[1][:status] <=> b[1][:status] }]
58
+ end
59
+
60
+ # returns all nanites that provide given service
61
+ def all(service)
62
+ nanites_providing(service)
63
+ end
64
+
65
+ # returns a random nanite
66
+ def random(service)
67
+ candidates = nanites_providing(service)
68
+ return [] if candidates.empty?
69
+
70
+ [candidates[rand(candidates.size)]]
71
+ end
72
+
73
+ # selects next nanite that provides given service
74
+ # using round robin rotation
75
+ def rr(service)
76
+ @last ||= {}
77
+ @last[service] ||= 0
78
+ candidates = nanites_providing(service)
79
+ return [] if candidates.empty?
80
+ @last[service] = 0 if @last[service] >= candidates.size
81
+ candidate = candidates[@last[service]]
82
+ @last[service] += 1
83
+ [candidate]
84
+ end
85
+
86
+ # returns all nanites that provide the given service
87
+ def nanites_providing(service)
88
+ nanites.find_all { |name, state| state[:services].include?(service) }
89
+ end
90
+
91
+ def setup_queues
92
+ setup_heartbeat_queue
93
+ setup_registration_queue
94
+ end
95
+
96
+ def setup_heartbeat_queue
97
+ amq.queue("heartbeat-#{identity}", :exclusive => true).bind(amq.fanout('heartbeat', :durable => true)).subscribe do |ping|
98
+ log.debug('got heartbeat')
99
+ handle_ping(serializer.load(ping))
100
+ end
101
+ end
102
+
103
+ def setup_registration_queue
104
+ amq.queue("registration-#{identity}", :exclusive => true).bind(amq.fanout('registration', :durable => true)).subscribe do |msg|
105
+ log.debug('got registration')
106
+ register(serializer.load(msg))
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,80 @@
1
+ module Nanite
2
+
3
+ COMMON_DEFAULT_OPTIONS = {:pass => 'testing', :vhost => '/nanite', :secure => false, :host => '0.0.0.0',
4
+ :log_level => :info, :format => :marshal, :daemonize => false, :console => false, :root => Dir.pwd}
5
+
6
+ module CommonConfig
7
+ def setup_mapper_options(opts, options)
8
+ setup_common_options(opts, options, 'mapper')
9
+
10
+ opts.on("-a", "--agent-timeout", "How long to wait before an agent is considered to be offline and thus removed from the list of available agents.") do |timeout|
11
+ options[:agent_timeout] = timeout
12
+ end
13
+
14
+ opts.on("-r", "--offline-redelivery-frequency", "The frequency in seconds that messages stored in the offline queue will be retrieved for attempted redelivery to the nanites. Default is 10 seconds.") do |frequency|
15
+ options[:offline_redelivery_frequency] = frequency
16
+ end
17
+
18
+ opts.on("--persistent", "Instructs the AMQP broker to save messages to persistent storage so that they aren't lost when the broker is restarted. Can be overriden on a per-message basis using the request and push methods.") do
19
+ options[:persistent] = true
20
+ end
21
+
22
+ opts.on("--offline-failsafe", "Store messages in an offline queue when all the nanites are offline. Messages will be redelivered when nanites come online. Can be overriden on a per-message basis using the request methods.") do
23
+ options[:offline_failsafe] = true
24
+ end
25
+ end
26
+
27
+ def setup_common_options(opts, options, type)
28
+ opts.version = Nanite::VERSION
29
+
30
+ opts.on("-i", "--irb-console", "Start #{type} in irb console mode.") do |console|
31
+ options[:console] = 'irb'
32
+ end
33
+
34
+ opts.on("-u", "--user USER", "Specify the rabbitmq username.") do |user|
35
+ options[:user] = user
36
+ end
37
+
38
+ opts.on("-h", "--host HOST", "Specify the rabbitmq hostname.") do |host|
39
+ options[:host] = host
40
+ end
41
+
42
+ opts.on("-P", "--port PORT", "Specify the rabbitmq PORT, default 5672.") do |port|
43
+ options[:port] = port
44
+ end
45
+
46
+ opts.on("-p", "--pass PASSWORD", "Specify the rabbitmq password") do |pass|
47
+ options[:pass] = pass
48
+ end
49
+
50
+ opts.on("-t", "--token IDENITY", "Specify the #{type} identity.") do |ident|
51
+ options[:identity] = ident
52
+ end
53
+
54
+ opts.on("-v", "--vhost VHOST", "Specify the rabbitmq vhost") do |vhost|
55
+ options[:vhost] = vhost
56
+ end
57
+
58
+ opts.on("-s", "--secure", "Use Security features of rabbitmq to restrict nanites to themselves") do
59
+ options[:secure] = true
60
+ end
61
+
62
+ opts.on("-f", "--format FORMAT", "The serialization type to use for transfering data. Can be marshal, json or yaml. Default is marshal") do |json|
63
+ options[:format] = :json
64
+ end
65
+
66
+ opts.on("-d", "--daemonize", "Run #{type} as a daemon") do |d|
67
+ options[:daemonize] = true
68
+ end
69
+
70
+ opts.on("-l", "--log-level LEVEL", "Specify the log level (fatal, error, warn, info, debug). Default is info") do |level|
71
+ options[:log_level] = level
72
+ end
73
+
74
+ opts.on("--version", "Show the nanite version number") do |res|
75
+ puts "Nanite Version #{opts.version}"
76
+ exit
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,39 @@
1
+ module Nanite
2
+ module ConsoleHelper
3
+ def self.included(base)
4
+ @@base = base
5
+ end
6
+
7
+ def start_console
8
+ puts "Starting #{@@base.name.split(":").last.downcase} console (#{self.identity}) (Nanite #{Nanite::VERSION})"
9
+ Thread.new do
10
+ Console.start(self)
11
+ end
12
+ end
13
+ end
14
+
15
+ module Console
16
+ class << self; attr_accessor :instance; end
17
+
18
+ def self.start(binding)
19
+ require 'irb'
20
+ old_args = ARGV.dup
21
+ ARGV.replace ["--simple-prompt"]
22
+
23
+ IRB.setup(nil)
24
+ self.instance = IRB::Irb.new(IRB::WorkSpace.new(binding))
25
+
26
+ @CONF = IRB.instance_variable_get(:@CONF)
27
+ @CONF[:IRB_RC].call self.instance.context if @CONF[:IRB_RC]
28
+ @CONF[:MAIN_CONTEXT] = self.instance.context
29
+
30
+ catch(:IRB_EXIT) { self.instance.eval_input }
31
+ ensure
32
+ ARGV.replace old_args
33
+ # Clean up tty settings in some evil, evil cases
34
+ begin; catch(:IRB_EXIT) { irb_exit }; rescue Exception; end
35
+ # Make nanite exit when irb does
36
+ EM.stop if EM.reactor_running?
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,12 @@
1
+ module Nanite
2
+ module DaemonizeHelper
3
+ def daemonize
4
+ exit if fork
5
+ Process.setsid
6
+ exit if fork
7
+ $stdin.reopen("/dev/null")
8
+ $stdout.reopen(log.file, "a")
9
+ $stderr.reopen($stdout)
10
+ end
11
+ end
12
+ end