├── examples ├── rack-worker │ ├── files │ │ └── test.txt │ ├── config.yml │ ├── init.rb │ └── actors │ │ └── rack_worker.rb ├── rabbitmqctl │ ├── init.rb │ └── actors │ │ └── rabbit.rb ├── simpleagent │ ├── init.rb │ ├── config.yml │ └── actors │ │ └── simple.rb ├── crew.rb ├── rabbitconf.rb ├── async_rack_front │ └── async_rack_front.ru └── cli.rb ├── docs ├── JamesRH_Lisa.pdf └── links.txt ├── spec ├── spec_helper.rb ├── actor_registry_spec.rb ├── actor_spec.rb ├── util_spec.rb ├── dispatcher_spec.rb ├── serializer_spec.rb ├── packet_spec.rb ├── agent_spec.rb └── cluster_spec.rb ├── .gitignore ├── lib ├── nanite │ ├── daemonize.rb │ ├── identity.rb │ ├── actor_registry.rb │ ├── reaper.rb │ ├── console.rb │ ├── job.rb │ ├── log │ │ └── formatter.rb │ ├── serializer.rb │ ├── util.rb │ ├── actor.rb │ ├── amqp.rb │ ├── dispatcher.rb │ ├── log.rb │ ├── config.rb │ ├── cluster.rb │ ├── streaming.rb │ ├── admin.rb │ ├── agent.rb │ ├── packets.rb │ └── mapper.rb └── nanite.rb ├── bin ├── nanite-mapper ├── nanite-agent └── nanite-admin ├── nanite.gemspec ├── TODO ├── Rakefile ├── tasks └── rabbitmq.rake ├── LICENSE └── README.rdoc /examples/rack-worker/files/test.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/rabbitmqctl/init.rb: -------------------------------------------------------------------------------- 1 | register(Rabbit.new) -------------------------------------------------------------------------------- /examples/simpleagent/init.rb: -------------------------------------------------------------------------------- 1 | register Simple.new -------------------------------------------------------------------------------- /docs/JamesRH_Lisa.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b/nanite/master/docs/JamesRH_Lisa.pdf -------------------------------------------------------------------------------- /examples/simpleagent/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | :user: nanite 3 | :pass: testing 4 | :vhost: /nanite 5 | :log_level: debug -------------------------------------------------------------------------------- /examples/rack-worker/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | :pass: testing 3 | :vhost: /nanite 4 | :user: nanite 5 | :identity: fred 6 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'spec' 3 | $TESTING=true 4 | $:.push File.join(File.dirname(__FILE__), '..', 'lib') 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.DS_Store 2 | \#* 3 | .#* 4 | .emacs* 5 | pkg 6 | db 7 | vendor 8 | *.log 9 | rdoc 10 | config.yml 11 | spec/config.yml 12 | *.tmproj -------------------------------------------------------------------------------- /examples/rack-worker/init.rb: -------------------------------------------------------------------------------- 1 | app = Proc.new do |env| 2 | [200, {'Content-Type'=>'text/html'}, "hello world!"] 3 | end 4 | 5 | register(RackWorker.new(app), 'rack') 6 | -------------------------------------------------------------------------------- /examples/rack-worker/actors/rack_worker.rb: -------------------------------------------------------------------------------- 1 | class RackWorker 2 | include Nanite::Actor 3 | expose :call 4 | 5 | def initialize(app) 6 | @app = app 7 | end 8 | 9 | def call(env) 10 | @app.call(env) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/nanite/daemonize.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /lib/nanite/identity.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | module Identity 3 | def self.generate 4 | values = [ 5 | rand(0x0010000), 6 | rand(0x0010000), 7 | rand(0x0010000), 8 | rand(0x0010000), 9 | rand(0x0010000), 10 | rand(0x1000000), 11 | rand(0x1000000), 12 | ] 13 | "%04x%04x%04x%04x%04x%06x%06x" % values 14 | end 15 | end 16 | end -------------------------------------------------------------------------------- /examples/simpleagent/actors/simple.rb: -------------------------------------------------------------------------------- 1 | # you can execute this nanite from the cli.rb command line example app 2 | 3 | class Simple 4 | include Nanite::Actor 5 | expose :echo, :time, :gems 6 | 7 | def echo(payload) 8 | "Nanite said #{payload.empty? ? "nothing at all" : payload} @ #{Time.now.to_s}" 9 | end 10 | 11 | def time(payload) 12 | Time.now 13 | end 14 | 15 | def gems(filter) 16 | ::Gem.source_index.refresh!.search(filter).flatten.collect {|gemspec| "#{gemspec.name} #{gemspec.version}"} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /bin/nanite-mapper: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require File.dirname(__FILE__) + '/../lib/nanite' 4 | require 'optparse' 5 | 6 | include Nanite::CommonConfig 7 | 8 | options = {} 9 | 10 | opts = OptionParser.new do |opts| 11 | opts.banner = "Usage: nanite-mapper [-flags] [argument]" 12 | opts.define_head "Nanite Mapper: clustered head unit for self assembling cluster of ruby processes." 13 | opts.separator '*'*80 14 | 15 | setup_mapper_options(opts, options) 16 | end 17 | 18 | opts.parse! 19 | 20 | EM.run do 21 | Nanite.start_mapper(options) 22 | end 23 | -------------------------------------------------------------------------------- /examples/crew.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | agents = %w[tom jay edward yehuda james corey josh ezra womble jeff loren lance sam kevin kirk] 4 | 5 | def process_exists?(str) 6 | result = `ps auwx | grep '#{str}' | grep -v grep | grep -v tail` 7 | !result.empty? 8 | end 9 | 10 | def run_agent(name, num, root) 11 | if !process_exists?(name) 12 | system("#{File.dirname(__FILE__)}/nanite-agent -u #{name} -p testing -t #{name} -n #{root} -j &") 13 | end 14 | end 15 | 16 | agents.each_with_index do |a,idx| 17 | run_agent(a, idx, "/Users/ez/nanite/examples/rack-worker") 18 | end 19 | 20 | -------------------------------------------------------------------------------- /lib/nanite/actor_registry.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | class ActorRegistry 3 | attr_reader :actors 4 | 5 | def initialize 6 | @actors = {} 7 | end 8 | 9 | def register(actor, prefix) 10 | raise ArgumentError, "#{actor.inspect} is not a Nanite::Actor subclass instance" unless Nanite::Actor === actor 11 | Nanite::Log.info("Registering #{actor.inspect} with prefix #{prefix.inspect}") 12 | prefix ||= actor.class.default_prefix 13 | actors[prefix.to_s] = actor 14 | end 15 | 16 | def services 17 | actors.map {|prefix, actor| actor.class.provides_for(prefix) }.flatten.uniq 18 | end 19 | 20 | def actor_for(prefix) 21 | actor = actors[prefix] 22 | end 23 | end # ActorRegistry 24 | end # Nanite -------------------------------------------------------------------------------- /nanite.gemspec: -------------------------------------------------------------------------------- 1 | spec = Gem::Specification.new do |s| 2 | s.name = "nanite" 3 | s.version = "0.3.0" 4 | s.platform = Gem::Platform::RUBY 5 | s.has_rdoc = true 6 | s.extra_rdoc_files = ["README.rdoc", "LICENSE", 'TODO'] 7 | s.summary = "self assembling fabric of ruby daemons" 8 | s.description = s.summary 9 | s.author = "Ezra Zygmuntowicz" 10 | s.email = "ezra@engineyard.com" 11 | s.homepage = "http://github.com/ezmobius/nanite" 12 | 13 | s.bindir = "bin" 14 | s.executables = %w( nanite-agent nanite-mapper nanite-admin ) 15 | 16 | s.add_dependency "extlib" 17 | s.add_dependency('amqp', '>= 0.6.0') 18 | 19 | s.require_path = 'lib' 20 | s.files = %w(LICENSE README.rdoc Rakefile TODO) + Dir.glob("{lib,bin,specs}/**/*") 21 | end 22 | -------------------------------------------------------------------------------- /lib/nanite/reaper.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | class Reaper 3 | 4 | def initialize(frequency=2) 5 | @timeouts = {} 6 | EM.add_periodic_timer(frequency) { EM.next_tick { reap } } 7 | end 8 | 9 | def timeout(token, seconds, &blk) 10 | @timeouts[token] = {:timestamp => Time.now + seconds, :seconds => seconds, :callback => blk} 11 | end 12 | 13 | def reset(token) 14 | @timeouts[token][:timestamp] = Time.now + @timeouts[token][:seconds] 15 | end 16 | 17 | private 18 | 19 | def reap 20 | @timeouts.reject! do |token, data| 21 | if Time.now > data[:timestamp] 22 | data[:callback].call 23 | true 24 | else 25 | false 26 | end 27 | end 28 | end 29 | end 30 | end -------------------------------------------------------------------------------- /docs/links.txt: -------------------------------------------------------------------------------- 1 | http://saladwithsteve.com/2007/08/future-is-messaging.html 2 | http://lists.rabbitmq.com/pipermail/rabbitmq-discuss/2008-June/001287.html 3 | http://hopper.squarespace.com/blog/2008/6/22/introducing-shovel-an-amqp-relay.html 4 | http://hopper.squarespace.com/blog/2008/1/12/introducing-the-erlang-amqp-client.html 5 | http://blog.folknology.com/2008/05/31/reactored-my-packet-based-future-finally-emerges/ 6 | http://hopper.squarespace.com/blog/2008/6/21/build-your-own-amqp-client.html 7 | http://www.lshift.net/news.20071116intelrabbit 8 | http://www.acmqueue.com/modules.php?name=Content&pa=showpage&pid=485 9 | http://www.slideshare.net/Georgio_1999/rubymanor-presentation/ 10 | 11 | AMQP 12 | -- 13 | http://hopper.squarespace.com/blog/2008/7/22/simple-amqp-library-for-ruby.html 14 | http://everburning.com/news/ridding-the-rabbit/ -------------------------------------------------------------------------------- /examples/rabbitconf.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | puts `rabbitmqctl add_vhost /nanite` 4 | 5 | # create 'mapper' and 'nanite' users, give them each the password 'testing' 6 | %w[mapper nanite].each do |agent| 7 | puts `rabbitmqctl add_user #{agent} testing` 8 | puts `rabbitmqctl map_user_vhost #{agent} /nanite` 9 | end 10 | 11 | # grant the mapper user the ability to do anything with the /nanite vhost 12 | # the three regex's map to config, write, read permissions respectively 13 | puts `rabbitmqctl set_permissions -p /nanite mapper ".*" ".*" ".*"` 14 | 15 | # grant the nanite user more limited permissions on the /nanite vhost 16 | puts `rabbitmqctl set_permissions -p /nanite nanite "^nanite.*" ".*" ".*"` 17 | 18 | puts `rabbitmqctl list_users` 19 | puts `rabbitmqctl list_vhosts` 20 | puts `rabbitmqctl list_permissions -p /nanite` -------------------------------------------------------------------------------- /bin/nanite-agent: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require File.dirname(__FILE__) + '/../lib/nanite' 4 | require 'optparse' 5 | 6 | include Nanite::CommonConfig 7 | 8 | options = {} 9 | 10 | opts = OptionParser.new do |opts| 11 | opts.banner = "Usage: nanite-agent [-flag] [argument]" 12 | opts.define_head "Nanite Agent: ruby process that acts upon messages passed to it by a mapper." 13 | opts.separator '*'*80 14 | 15 | setup_common_options(opts, options, 'agent') 16 | 17 | opts.on("-n", "--nanite NANITE_ROOT", "Specify the root of your nanite agent project.") do |nanite| 18 | options[:root] = nanite 19 | end 20 | 21 | opts.on("--ping-time PINGTIME", "Specify how often the agents contacts the mapper") do |ping| 22 | options[:ping_time] = ping 23 | end 24 | end 25 | 26 | opts.parse! 27 | 28 | EM.run do 29 | Nanite.start_agent(options) 30 | end 31 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | TODO: 2 | 3 | - The examples/crew.rb file is pointing towards a hard coded user dir. Needs to be 4 | documented as part of a working example. 5 | - examples/async_rack_front/async_rack_front.ru needs to be documented and verified working. 6 | 7 | Ian: 8 | - Sync Mapper/Agent#start with nanite-mapper/agent 9 | - Update docs for Agent#start and Mapper#start 10 | - Update docs in nanite-agent and nanite-mapper 11 | - Ensure file transfer works 12 | - Check secure stuff still works 13 | - Check custom status_proc works 14 | - Check documentation, only document public methods 15 | - ensure the removal of threaded_actors option doesn't cause shit to block 16 | 17 | - Look into using EM deferables for actors dispatch. 18 | - Integration specs that spawn a small cluster of nanites 19 | - Rename Ping to Status 20 | - request/push should take *args for payload? 21 | 22 | Maybe: 23 | - Make mapper queue durable and Results respect :persistent flag on the request 24 | - Add a global result received callback -------------------------------------------------------------------------------- /examples/async_rack_front/async_rack_front.ru: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'nanite' 3 | require 'nanite/mapper' 4 | 5 | # you need raggi's patched async version of thin: 6 | # git clone git://github.com/raggi/thin.git 7 | # cd thin 8 | # git branch async 9 | # git checkout async 10 | # git pull origin async_for_rack 11 | # rake install 12 | # thin -R async_rack_front.ru -p 4000 start 13 | 14 | class NaniteApp 15 | 16 | AsyncResponse = [-1, {}, []].freeze 17 | 18 | def call(env) 19 | mapper = Nanite::Mapper.start 20 | def call(env) 21 | env.delete('rack.errors') 22 | input = env.delete('rack.input') 23 | async_callback = env.delete('async.callback') 24 | 25 | mapper.request('/rack/call', env, :selector => :random, :timeout => 15) do |response| 26 | if response 27 | async_callback.call response.values.first 28 | else 29 | async_callback.call [500, {'Content-Type' => 'text/html'}, "Request Timeout"] 30 | end 31 | end 32 | AsyncResponse 33 | end 34 | [200, {'Content-Type' => 'text/html'}, "warmed up nanite mapper"] 35 | end 36 | end 37 | 38 | run NaniteApp.new 39 | -------------------------------------------------------------------------------- /lib/nanite/console.rb: -------------------------------------------------------------------------------- 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 40 | -------------------------------------------------------------------------------- /lib/nanite/job.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | class JobWarden 3 | attr_reader :serializer, :jobs 4 | 5 | def initialize(serializer) 6 | @serializer = serializer 7 | @jobs = {} 8 | end 9 | 10 | def new_job(request, targets, blk = nil) 11 | job = Job.new(request, targets, blk) 12 | jobs[job.token] = job 13 | job 14 | end 15 | 16 | def process(msg) 17 | msg = serializer.load(msg) 18 | Nanite::Log.debug("processing message: #{msg.inspect}") 19 | if job = jobs[msg.token] 20 | job.process(msg) 21 | if job.completed? 22 | jobs.delete(job.token) 23 | job.completed.call(job.results) if job.completed 24 | end 25 | end 26 | end 27 | end 28 | 29 | class Job 30 | attr_reader :results, :request, :token, :targets, :completed 31 | 32 | def initialize(request, targets, blk) 33 | @request = request 34 | @targets = targets 35 | @token = @request.token 36 | @results = {} 37 | @completed = blk 38 | end 39 | 40 | def process(msg) 41 | results[msg.from] = msg.results 42 | targets.delete(msg.from) 43 | end 44 | 45 | def completed? 46 | targets.empty? 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /lib/nanite/log/formatter.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'time' 3 | 4 | module Nanite 5 | class Log 6 | class Formatter < Logger::Formatter 7 | @@show_time = true 8 | 9 | def self.show_time=(show=false) 10 | @@show_time = show 11 | end 12 | 13 | # Prints a log message as '[time] severity: message' if Nanite::Log::Formatter.show_time == true. 14 | # Otherwise, doesn't print the time. 15 | def call(severity, time, progname, msg) 16 | if @@show_time 17 | sprintf("[%s] %s: %s\n", time.rfc2822(), severity, msg2str(msg)) 18 | else 19 | sprintf("%s: %s\n", severity, msg2str(msg)) 20 | end 21 | end 22 | 23 | # Converts some argument to a Logger.severity() call to a string. Regular strings pass through like 24 | # normal, Exceptions get formatted as "message (class)\nbacktrace", and other random stuff gets 25 | # put through "object.inspect" 26 | def msg2str(msg) 27 | case msg 28 | when ::String 29 | msg 30 | when ::Exception 31 | "#{ msg.message } (#{ msg.class })\n" << 32 | (msg.backtrace || []).join("\n") 33 | else 34 | msg.inspect 35 | end 36 | end 37 | end 38 | end 39 | end -------------------------------------------------------------------------------- /lib/nanite/serializer.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | class Serializer 3 | 4 | class SerializationError < StandardError 5 | attr_accessor :action, :packet 6 | def initialize(action, packet) 7 | @action, @packet = action, packet 8 | super("Could not #{action} #{packet.inspect} using #{SERIALIZERS.keys.join(', ')}") 9 | end 10 | end # SerializationError 11 | 12 | def initialize(preferred_format="marshal") 13 | preferred_format ||= "marshal" 14 | preferred_serializer = SERIALIZERS[preferred_format.to_sym] 15 | @serializers = SERIALIZERS.values.clone 16 | @serializers.unshift(@serializers.delete(preferred_serializer)) if preferred_serializer 17 | end 18 | 19 | def dump(packet) 20 | cascade_serializers(:dump, packet) 21 | end 22 | 23 | def load(packet) 24 | cascade_serializers(:load, packet) 25 | end 26 | 27 | private 28 | 29 | SERIALIZERS = {:json => JSON, :marshal => Marshal, :yaml => YAML}.freeze 30 | 31 | def cascade_serializers(action, packet) 32 | @serializers.map do |serializer| 33 | o = serializer.send(action, packet) rescue nil 34 | return o if o 35 | end 36 | raise SerializationError.new(action, packet) 37 | end 38 | 39 | end # Serializer 40 | end # Nanite 41 | -------------------------------------------------------------------------------- /examples/cli.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require File.dirname(__FILE__) + '/../lib/nanite' 4 | 5 | # cli.rb 6 | # 7 | # You will need to have run the examples/rabbitconf.rb script at least one time before you 8 | # run this so the expected users and vhosts are in place in RabbitMQ. 9 | # 10 | # You should also have started the 'simpleagent' nanite in a separate shell by running: 11 | # 12 | # cd /examples/simpleagent 13 | # nanite 14 | # 15 | # This test script takes a little more than 16 seconds to run since we start a new 16 | # mapper within, and pause while we wait for it to initialize, receive pings from 17 | # available agents (which have a default ping time of 15 seconds), and register 18 | # those agents and their methods. When this process is presumed complete after 19 | # 16 seconds we can finally send the nanite agent the task to execute. 20 | 21 | EM.run do 22 | # start up a new mapper with a ping time of 15 seconds 23 | Nanite.start_mapper(:host => 'localhost', :user => 'mapper', :pass => 'testing', :vhost => '/nanite', :log_level => 'info') 24 | 25 | # have this run after 16 seconds so we can be pretty sure that the mapper 26 | # has already received pings from running nanites and registered them. 27 | EM.add_timer(16) do 28 | # call our /simple/echo nanite, and pass it a string to echo back 29 | Nanite.request("/simple/echo", "hello world!") do |res| 30 | p res 31 | EM.stop_event_loop 32 | end 33 | end 34 | end 35 | 36 | -------------------------------------------------------------------------------- /lib/nanite.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'amqp' 3 | require 'mq' 4 | require 'json' 5 | require 'logger' 6 | require 'yaml' 7 | 8 | $:.unshift File.dirname(__FILE__) 9 | require 'nanite/amqp' 10 | require 'nanite/util' 11 | require 'nanite/config' 12 | require 'nanite/packets' 13 | require 'nanite/identity' 14 | require 'nanite/console' 15 | require 'nanite/daemonize' 16 | require 'nanite/job' 17 | require 'nanite/mapper' 18 | require 'nanite/actor' 19 | require 'nanite/actor_registry' 20 | require 'nanite/streaming' 21 | require 'nanite/dispatcher' 22 | require 'nanite/agent' 23 | require 'nanite/cluster' 24 | require 'nanite/reaper' 25 | require 'nanite/serializer' 26 | require 'nanite/log' 27 | 28 | module Nanite 29 | VERSION = '0.3.0' unless defined?(Nanite::VERSION) 30 | 31 | class MapperNotRunning < StandardError; end 32 | 33 | class << self 34 | attr_reader :mapper, :agent 35 | 36 | def start_agent(options = {}) 37 | @agent = Nanite::Agent.start(options) 38 | end 39 | 40 | def start_mapper(options = {}) 41 | @mapper = Nanite::Mapper.start(options) 42 | end 43 | 44 | def request(*args, &blk) 45 | ensure_mapper 46 | @mapper.request(*args, &blk) 47 | end 48 | 49 | def push(*args) 50 | ensure_mapper 51 | @mapper.push(*args) 52 | end 53 | 54 | def ensure_mapper 55 | raise MapperNotRunning.new('A mapper needs to be started via Nanite.start_mapper') unless @mapper 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/nanite/util.rb: -------------------------------------------------------------------------------- 1 | class String 2 | ## 3 | # Convert to snake case. 4 | # 5 | # "FooBar".snake_case #=> "foo_bar" 6 | # "HeadlineCNNNews".snake_case #=> "headline_cnn_news" 7 | # "CNN".snake_case #=> "cnn" 8 | # 9 | # @return [String] Receiver converted to snake case. 10 | # 11 | # @api public 12 | def snake_case 13 | return self.downcase if self =~ /^[A-Z]+$/ 14 | self.gsub(/([A-Z]+)(?=[A-Z][a-z]?)|\B[A-Z]/, '_\&') =~ /_*(.*)/ 15 | return $+.downcase 16 | end 17 | 18 | ## 19 | # Convert a constant name to a path, assuming a conventional structure. 20 | # 21 | # "FooBar::Baz".to_const_path # => "foo_bar/baz" 22 | # 23 | # @return [String] Path to the file containing the constant named by receiver 24 | # (constantized string), assuming a conventional structure. 25 | # 26 | # @api public 27 | def to_const_path 28 | snake_case.gsub(/::/, "/") 29 | end 30 | end 31 | 32 | class Object 33 | module InstanceExecHelper; end 34 | include InstanceExecHelper 35 | def instance_exec(*args, &block) 36 | begin 37 | old_critical, Thread.critical = Thread.critical, true 38 | n = 0 39 | n += 1 while respond_to?(mname="__instance_exec#{n}") 40 | InstanceExecHelper.module_eval{ define_method(mname, &block) } 41 | ensure 42 | Thread.critical = old_critical 43 | end 44 | begin 45 | ret = send(mname, *args) 46 | ensure 47 | InstanceExecHelper.module_eval{ remove_method(mname) } rescue nil 48 | end 49 | ret 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/actor_registry_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | require 'nanite' 3 | 4 | 5 | describe Nanite::ActorRegistry do 6 | 7 | before(:all) do 8 | class WebDocumentImporter 9 | include Nanite::Actor 10 | expose :import, :cancel 11 | 12 | def import 13 | 1 14 | end 15 | def cancel 16 | 0 17 | end 18 | end 19 | 20 | module Actors 21 | class ComedyActor 22 | include Nanite::Actor 23 | expose :fun_tricks 24 | def fun_tricks 25 | :rabbit_in_the_hat 26 | end 27 | end 28 | end 29 | end 30 | 31 | before(:each) do 32 | Nanite::Log.stub! :info 33 | @registry = Nanite::ActorRegistry.new 34 | end 35 | 36 | it "should know about all services" do 37 | @registry.register(WebDocumentImporter.new, nil) 38 | @registry.register(Actors::ComedyActor.new, nil) 39 | @registry.services.sort.should == ["/actors/comedy_actor/fun_tricks", "/web_document_importer/cancel", "/web_document_importer/import"] 40 | end 41 | 42 | it "should not register anything except Nanite::Actor" do 43 | lambda{@registry.register(String.new, nil)}.should raise_error(ArgumentError) 44 | end 45 | 46 | it "should register an actor" do 47 | importer = WebDocumentImporter.new 48 | @registry.register(importer, nil) 49 | @registry.actors['web_document_importer'].should == importer 50 | end 51 | 52 | it "should handle actors registered with a custom prefix" do 53 | importer = WebDocumentImporter.new 54 | @registry.register(importer, 'monkey') 55 | @registry.actors['monkey'].should == importer 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/actor_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | require 'nanite' 3 | 4 | class WebDocumentImporter 5 | include Nanite::Actor 6 | expose :import, :cancel 7 | 8 | def import 9 | 1 10 | end 11 | def cancel 12 | 0 13 | end 14 | def continue 15 | 1 16 | end 17 | end 18 | 19 | module Actors 20 | class ComedyActor 21 | include Nanite::Actor 22 | expose :fun_tricks 23 | def fun_tricks 24 | :rabbit_in_the_hat 25 | end 26 | end 27 | end 28 | 29 | describe Nanite::Actor do 30 | 31 | describe ".expose" do 32 | it "should single expose method only once" do 33 | 3.times { WebDocumentImporter.expose(:continue) } 34 | WebDocumentImporter.provides_for("webfiles").should == ["/webfiles/import", "/webfiles/cancel", "/webfiles/continue"] 35 | end 36 | end 37 | 38 | describe ".default_prefix" do 39 | it "is calculated as default prefix as const path of class name" do 40 | Actors::ComedyActor.default_prefix.should == "actors/comedy_actor" 41 | WebDocumentImporter.default_prefix.should == "web_document_importer" 42 | end 43 | end 44 | 45 | describe ".provides_for(prefix)" do 46 | before :each do 47 | @provides = Actors::ComedyActor.provides_for("money") 48 | end 49 | it "returns an array" do 50 | @provides.should be_kind_of(Array) 51 | end 52 | 53 | it "maps exposed service methods to prefix" do 54 | @provides.should == ["/money/fun_tricks"] 55 | wdi_provides = WebDocumentImporter.provides_for("webfiles") 56 | wdi_provides.should include("/webfiles/import") 57 | wdi_provides.should include("/webfiles/cancel") 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/nanite/actor.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | # This mixin provides Nanite actor functionality. 3 | # 4 | # To use it simply include it your class containing the functionality to be exposed: 5 | # 6 | # class Foo 7 | # include Nanite::Actor 8 | # expose :bar 9 | # 10 | # def bar(payload) 11 | # # ... 12 | # end 13 | # 14 | # end 15 | module Actor 16 | 17 | def self.included(base) 18 | base.class_eval do 19 | include Nanite::Actor::InstanceMethods 20 | extend Nanite::Actor::ClassMethods 21 | end # base.class_eval 22 | end # self.included 23 | 24 | module ClassMethods 25 | def default_prefix 26 | to_s.to_const_path 27 | end 28 | 29 | def expose(*meths) 30 | @exposed ||= [] 31 | meths.each do |meth| 32 | @exposed << meth unless @exposed.include?(meth) 33 | end 34 | end 35 | 36 | def provides_for(prefix) 37 | return [] unless @exposed 38 | @exposed.map {|meth| "/#{prefix}/#{meth}".squeeze('/')} 39 | end 40 | 41 | def on_exception(proc = nil, &blk) 42 | raise 'No callback provided for on_exception' unless proc || blk 43 | if Nanite::Actor == self 44 | raise 'Method name callbacks cannot be used on the Nanite::Actor superclass' if Symbol === proc || String === proc 45 | @superclass_exception_callback = proc || blk 46 | else 47 | @instance_exception_callback = proc || blk 48 | end 49 | end 50 | 51 | def superclass_exception_callback 52 | @superclass_exception_callback 53 | end 54 | 55 | def instance_exception_callback 56 | @instance_exception_callback 57 | end 58 | end # ClassMethods 59 | 60 | module InstanceMethods 61 | end # InstanceMethods 62 | 63 | end # Actor 64 | end # Nanite -------------------------------------------------------------------------------- /lib/nanite/amqp.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /spec/util_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | require 'nanite/util' 3 | 4 | describe String do 5 | 6 | describe ".snake_case" do 7 | 8 | it "should downcase single word" do 9 | ["FOO", "Foo", "foo"].each do |w| 10 | w.snake_case.should == "foo" 11 | end 12 | end 13 | 14 | it "should not separate numbers from end of word" do 15 | ["Foo1234", "foo1234"].each do |w| 16 | w.snake_case.should == "foo1234" 17 | end 18 | end 19 | 20 | it "should separate numbers from word it starts with uppercase letter" do 21 | "1234Foo".snake_case.should == "1234_foo" 22 | end 23 | 24 | it "should not separate numbers from word starts with lowercase letter" do 25 | "1234foo".snake_case.should == "1234foo" 26 | end 27 | 28 | it "should downcase camel-cased words and connect with underscore" do 29 | ["FooBar", "fooBar"].each do |w| 30 | w.snake_case.should == "foo_bar" 31 | end 32 | end 33 | 34 | it "should start new word with uppercase letter before lower case letter" do 35 | ["FooBARBaz", "fooBARBaz"].each do |w| 36 | w.snake_case.should == "foo_bar_baz" 37 | end 38 | end 39 | 40 | end 41 | 42 | describe ".to_const_path" do 43 | 44 | it "should snake-case the string" do 45 | str = "hello" 46 | str.should_receive(:snake_case).and_return("snake-cased hello") 47 | str.to_const_path 48 | end 49 | 50 | it "should leave (snake-cased) string without '::' unchanged" do 51 | "hello".to_const_path.should == "hello" 52 | end 53 | 54 | it "should replace single '::' with '/'" do 55 | "hello::world".to_const_path.should == "hello/world" 56 | end 57 | 58 | it "should replace multiple '::' with '/'" do 59 | "hello::nanite::world".to_const_path.should == "hello/nanite/world" 60 | end 61 | 62 | end 63 | 64 | end # String 65 | -------------------------------------------------------------------------------- /lib/nanite/dispatcher.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | class Dispatcher 3 | attr_reader :registry, :serializer, :identity, :amq, :options 4 | 5 | def initialize(amq, registry, serializer, identity, options) 6 | @amq = amq 7 | @registry = registry 8 | @serializer = serializer 9 | @identity = identity 10 | @options = options 11 | end 12 | 13 | def dispatch(deliverable) 14 | result = begin 15 | prefix, meth = deliverable.type.split('/')[1..-1] 16 | actor = registry.actor_for(prefix) 17 | actor.send((meth.nil? ? :index : meth), deliverable.payload) 18 | rescue Exception => e 19 | handle_exception(actor, meth, deliverable, e) 20 | end 21 | 22 | if deliverable.kind_of?(Request) 23 | result = Result.new(deliverable.token, deliverable.reply_to, result, identity) 24 | amq.queue(deliverable.reply_to, :no_declare => options[:secure]).publish(serializer.dump(result)) 25 | end 26 | 27 | result 28 | end 29 | 30 | private 31 | 32 | def describe_error(e) 33 | "#{e.class.name}: #{e.message}\n #{e.backtrace.join("\n ")}" 34 | end 35 | 36 | def handle_exception(actor, meth, deliverable, e) 37 | error = describe_error(e) 38 | Nanite::Log.error(error) 39 | begin 40 | if actor.class.instance_exception_callback 41 | case actor.class.instance_exception_callback 42 | when Symbol, String 43 | actor.send(actor.class.instance_exception_callback, meth.to_sym, deliverable, e) 44 | when Proc 45 | actor.instance_exec(meth.to_sym, deliverable, e, &actor.class.instance_exception_callback) 46 | end 47 | end 48 | if Nanite::Actor.superclass_exception_callback 49 | Nanite::Actor.superclass_exception_callback.call(actor, meth.to_sym, deliverable, e) 50 | end 51 | rescue Exception => e1 52 | error = describe_error(e1) 53 | Nanite::Log.error(error) 54 | end 55 | error 56 | end 57 | end 58 | end -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake/gempackagetask' 3 | require "spec/rake/spectask" 4 | begin; require 'rubygems'; rescue LoadError; end 5 | begin 6 | require 'hanna/rdoctask' 7 | rescue LoadError 8 | require 'rake/rdoctask' 9 | end 10 | require 'rake/clean' 11 | 12 | GEM = "nanite" 13 | VER = "0.3.0" 14 | AUTHOR = "Ezra Zygmuntowicz" 15 | EMAIL = "ezra@engineyard.com" 16 | HOMEPAGE = "http://github.com/ezmobius/nanite" 17 | SUMMARY = "self assembling fabric of ruby daemons" 18 | 19 | Dir.glob('tasks/*.rake').each { |r| Rake.application.add_import r } 20 | 21 | spec = Gem::Specification.new do |s| 22 | s.name = GEM 23 | s.version = ::VER 24 | s.platform = Gem::Platform::RUBY 25 | s.has_rdoc = true 26 | s.extra_rdoc_files = ["README.rdoc", "LICENSE", 'TODO'] 27 | s.summary = SUMMARY 28 | s.description = s.summary 29 | s.author = AUTHOR 30 | s.email = EMAIL 31 | s.homepage = HOMEPAGE 32 | 33 | s.bindir = "bin" 34 | s.executables = %w( nanite-agent nanite-mapper nanite-admin ) 35 | 36 | s.add_dependency "extlib" 37 | s.add_dependency('amqp', '>= 0.6.0') 38 | 39 | s.require_path = 'lib' 40 | s.files = %w(LICENSE README.rdoc Rakefile TODO) + Dir.glob("{lib,bin,specs}/**/*") 41 | end 42 | 43 | Rake::GemPackageTask.new(spec) do |pkg| 44 | pkg.gem_spec = spec 45 | end 46 | 47 | task :install => [:package] do 48 | sh %{sudo gem install pkg/#{GEM}-#{VER}} 49 | end 50 | 51 | desc "Run unit specs" 52 | Spec::Rake::SpecTask.new do |t| 53 | t.spec_opts = ["--format", "specdoc", "--colour"] 54 | t.spec_files = FileList["spec/**/*_spec.rb"] 55 | end 56 | 57 | desc 'Generate RDoc documentation' 58 | Rake::RDocTask.new do |rd| 59 | rd.title = spec.name 60 | rd.rdoc_dir = 'rdoc' 61 | rd.main = "README.rdoc" 62 | rd.rdoc_files.include("lib/**/*.rb", *spec.extra_rdoc_files) 63 | end 64 | CLOBBER.include(:clobber_rdoc) 65 | 66 | desc 'Generate and open documentation' 67 | task :docs => :rdoc do 68 | case RUBY_PLATFORM 69 | when /darwin/ ; sh 'open rdoc/index.html' 70 | when /mswin|mingw/ ; sh 'start rdoc\index.html' 71 | else 72 | sh 'firefox rdoc/index.html' 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /bin/nanite-admin: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # To work without being installed as a gem: 4 | libdir = File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')) 5 | $:.unshift libdir unless $:.include? libdir 6 | 7 | require 'nanite' 8 | require 'nanite/admin' 9 | require 'eventmachine' 10 | require 'thin' 11 | # IMPORTANT! 12 | # You need raggi's patched async version of Thin at the moment to use 13 | # the nanite-admin tool. 14 | # 15 | # raggi's Git repo contains a branch called 'async_for_rack' which contains the 16 | # version of Thin you want to install. raggi has apparently removed the 'master' 17 | # branch of his Git repo so you may see a warning like that shown below. 18 | # 19 | # git clone git://github.com/raggi/thin.git thin-raggi-async 20 | # ... 21 | # warning: remote HEAD refers to nonexistent ref, unable to checkout. <<== IGNORE THIS 22 | # 23 | # cd thin-raggi-async/ 24 | # git checkout --track -b async_for_rack origin/async_for_rack 25 | # warning: You appear to be on a branch yet to be born. <<== IGNORE THIS 26 | # warning: Forcing checkout of origin/async_for_rack. <<== IGNORE THIS 27 | # Branch async_for_rack set up to track remote branch refs/remotes/origin/async_for_rack. 28 | # Switched to a new branch "async_for_rack" 29 | 30 | # run : 'rake install' to build and install the Thin gem 31 | # cd 32 | # ./bin/nanite-admin 33 | 34 | # When you need to update this Thin install you should be able to do a 'git pull' on the 35 | # "async_for_rack" branch. 36 | 37 | require File.dirname(__FILE__) + '/../lib/nanite' 38 | require 'yaml' 39 | require "optparse" 40 | 41 | include Nanite::CommonConfig 42 | 43 | options = {} 44 | 45 | opts = OptionParser.new do |opts| 46 | opts.banner = "Usage: nanite-admin [-flags] [argument]" 47 | opts.define_head "Nanite Admin: a basic control interface for your nanite cluster." 48 | opts.separator '*'*80 49 | 50 | setup_mapper_options(opts, options) 51 | end 52 | 53 | opts.parse! 54 | 55 | EM.run do 56 | Nanite.start_mapper(options) 57 | Nanite::Log.info "starting nanite-admin" 58 | Rack::Handler::Thin.run(Nanite::Admin.new(Nanite.mapper), :Port => 4000) 59 | end -------------------------------------------------------------------------------- /tasks/rabbitmq.rake: -------------------------------------------------------------------------------- 1 | # Inspired by rabbitmq.rake the Redbox project at http://github.com/rick/redbox/tree/master 2 | require 'fileutils' 3 | 4 | class RabbitMQ 5 | 6 | def self.basedir 7 | basedir = File.expand_path(File.dirname(__FILE__) + "/../") # ick 8 | end 9 | 10 | def self.rabbitdir 11 | "#{basedir}/vendor/rabbitmq-server-1.5.1" 12 | end 13 | 14 | def self.dtach_socket 15 | "#{basedir}/tmp/rabbitmq.dtach" 16 | end 17 | 18 | # Just check for existance of dtach socket 19 | def self.running? 20 | File.exists? dtach_socket 21 | end 22 | 23 | def self.setup_environment 24 | ENV['MNESIA_BASE'] ||= "#{basedir}/db/mnesia" 25 | ENV['LOG_BASE'] ||= "#{basedir}/log" 26 | 27 | # Kind of a hack around the way rabbitmq-server does args. I need to set 28 | # RABBITMQ_NODE_ONLY to prevent RABBITMQ_START_RABBIT from being set with -noinput. 29 | # Then RABBITMQ_SERVER_START_ARGS passes in the actual '-s rabbit' necessary. 30 | ENV['RABBITMQ_NODE_ONLY'] ||= "0" 31 | ENV['RABBITMQ_SERVER_START_ARGS'] ||= "-s rabbit" 32 | end 33 | 34 | def self.start 35 | setup_environment 36 | exec "dtach -A #{dtach_socket} #{rabbitdir}/scripts/rabbitmq-server" 37 | end 38 | 39 | def self.attach 40 | exec "dtach -a #{dtach_socket}" 41 | end 42 | 43 | def self.stop 44 | system "#{rabbitdir}/scripts/rabbitmqctl stop" 45 | end 46 | 47 | end 48 | 49 | namespace :rabbitmq do 50 | 51 | task :ensure_directories do 52 | FileUtils.mkdir_p("tmp") 53 | FileUtils.mkdir_p("log") 54 | FileUtils.mkdir_p("vendor") 55 | end 56 | 57 | desc "Start RabbitMQ" 58 | task :start => [:ensure_directories, :download] do 59 | RabbitMQ.start 60 | end 61 | 62 | desc "stop" 63 | task :stop do 64 | RabbitMQ.stop 65 | end 66 | 67 | desc "Attach to RabbitMQ dtach socket" 68 | task :attach do 69 | RabbitMQ.attach 70 | end 71 | 72 | desc "Download package" 73 | task :download do 74 | unless File.exists?(RabbitMQ.rabbitdir) 75 | FileUtils.mkdir_p("vendor") 76 | Dir.chdir("vendor") do 77 | system "curl http://www.rabbitmq.com/releases/rabbitmq-server/v1.5.1/rabbitmq-server-1.5.1.tar.gz -O && 78 | tar xvzf rabbitmq-server-1.5.1.tar.gz" 79 | end 80 | end 81 | end 82 | 83 | 84 | end 85 | -------------------------------------------------------------------------------- /lib/nanite/log.rb: -------------------------------------------------------------------------------- 1 | require 'nanite/config' 2 | require 'nanite/log/formatter' 3 | require 'logger' 4 | 5 | module Nanite 6 | class Log 7 | 8 | @logger = nil 9 | 10 | class << self 11 | attr_accessor :logger, :log_level #:nodoc 12 | 13 | # Use Nanite::Logger.init when you want to set up the logger manually. Arguments to this method 14 | # get passed directly to Logger.new, so check out the documentation for the standard Logger class 15 | # to understand what to do here. 16 | # 17 | # If this method is called with no arguments, it will log to STDOUT at the :info level. 18 | # 19 | # It also configures the Logger instance it creates to use the custom Nanite::Log::Formatter class. 20 | def init(identity, path = false) 21 | @file = STDOUT 22 | if path 23 | @file = File.join(path, "nanite.#{identity}.log") 24 | end 25 | @logger = Logger.new(@file) 26 | @logger.formatter = Nanite::Log::Formatter.new 27 | level(@log_level = :info) 28 | end 29 | 30 | # Sets the level for the Logger object by symbol. Valid arguments are: 31 | # 32 | # :debug 33 | # :info 34 | # :warn 35 | # :error 36 | # :fatal 37 | # 38 | # Throws an ArgumentError if you feed it a bogus log level. 39 | def level(loglevel) 40 | init() unless @logger 41 | case loglevel 42 | when :debug 43 | @logger.level = Logger::DEBUG 44 | when :info 45 | @logger.level = Logger::INFO 46 | when :warn 47 | @logger.level = Logger::WARN 48 | when :error 49 | @logger.level = Logger::ERROR 50 | when :fatal 51 | @logger.level = Logger::FATAL 52 | else 53 | raise ArgumentError, "Log level must be one of :debug, :info, :warn, :error, or :fatal" 54 | end 55 | end 56 | 57 | # Passes any other method calls on directly to the underlying Logger object created with init. If 58 | # this method gets hit before a call to Nanite::Logger.init has been made, it will call 59 | # Nanite::Logger.init() with no arguments. 60 | def method_missing(method_symbol, *args) 61 | init(identity) unless @logger 62 | if args.length > 0 63 | @logger.send(method_symbol, *args) 64 | else 65 | @logger.send(method_symbol) 66 | end 67 | end 68 | 69 | end # class << self 70 | end 71 | end -------------------------------------------------------------------------------- /lib/nanite/config.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /spec/dispatcher_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | require 'nanite' 3 | 4 | class Foo 5 | include Nanite::Actor 6 | expose :bar, :index, :i_kill_you 7 | on_exception :handle_exception 8 | 9 | def index(payload) 10 | bar(payload) 11 | end 12 | 13 | def bar(payload) 14 | ['hello', payload] 15 | end 16 | 17 | def i_kill_you(payload) 18 | raise RuntimeError.new('I kill you!') 19 | end 20 | 21 | def handle_exception(method, deliverable, error) 22 | end 23 | end 24 | 25 | class Bar 26 | include Nanite::Actor 27 | expose :i_kill_you 28 | on_exception do |method, deliverable, error| 29 | @scope = self 30 | @called_with = [method, deliverable, error] 31 | end 32 | 33 | def i_kill_you(payload) 34 | raise RuntimeError.new('I kill you!') 35 | end 36 | end 37 | 38 | # No specs, simply ensures multiple methods for assigning on_exception callback, 39 | # on_exception raises exception when called with an invalid argument. 40 | class Doomed 41 | include Nanite::Actor 42 | on_exception do 43 | end 44 | on_exception lambda {} 45 | on_exception :doh 46 | end 47 | 48 | describe "Nanite::Dispatcher" do 49 | before(:each) do 50 | amq = mock('amq', :queue => mock('queue', :publish => nil)) 51 | @actor = Foo.new 52 | @registry = Nanite::ActorRegistry.new 53 | @registry.register(@actor, nil) 54 | @dispatcher = Nanite::Dispatcher.new(amq, @registry, Nanite::Serializer.new(:marshal), '0xfunkymonkey', {}) 55 | end 56 | 57 | it "should dispatch a request" do 58 | req = Nanite::Request.new('/foo/bar', 'you') 59 | res = @dispatcher.dispatch(req) 60 | res.should(be_kind_of(Nanite::Result)) 61 | res.token.should == req.token 62 | res.results.should == ['hello', 'you'] 63 | end 64 | 65 | it "should dispatch a request to the default action" do 66 | req = Nanite::Request.new('/foo', 'you') 67 | res = @dispatcher.dispatch(req) 68 | res.should(be_kind_of(Nanite::Result)) 69 | res.token.should == req.token 70 | res.results.should == ['hello', 'you'] 71 | end 72 | 73 | it "should handle custom prefixes" do 74 | @registry.register(Foo.new, 'umbongo') 75 | req = Nanite::Request.new('/umbongo/bar', 'you') 76 | res = @dispatcher.dispatch(req) 77 | res.should(be_kind_of(Nanite::Result)) 78 | res.token.should == req.token 79 | res.results.should == ['hello', 'you'] 80 | end 81 | 82 | it "should call the on_exception callback if something goes wrong" do 83 | req = Nanite::Request.new('/foo/i_kill_you', nil) 84 | @actor.should_receive(:handle_exception).with(:i_kill_you, req, duck_type(:exception, :backtrace)) 85 | @dispatcher.dispatch(req) 86 | end 87 | 88 | it "should call on_exception Procs defined in a subclass with the correct arguments" do 89 | actor = Bar.new 90 | @registry.register(actor, nil) 91 | req = Nanite::Request.new('/bar/i_kill_you', nil) 92 | @dispatcher.dispatch(req) 93 | called_with = actor.instance_variable_get("@called_with") 94 | called_with[0].should == :i_kill_you 95 | called_with[1].should == req 96 | called_with[2].should be_kind_of(RuntimeError) 97 | called_with[2].message.should == 'I kill you!' 98 | end 99 | 100 | it "should call on_exception Procs defined in a subclass in the scope of the actor" do 101 | actor = Bar.new 102 | @registry.register(actor, nil) 103 | req = Nanite::Request.new('/bar/i_kill_you', nil) 104 | @dispatcher.dispatch(req) 105 | actor.instance_variable_get("@scope").should == actor 106 | end 107 | end 108 | 109 | -------------------------------------------------------------------------------- /lib/nanite/cluster.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | class Cluster 3 | attr_reader :agent_timeout, :nanites, :reaper, :serializer, :identity, :amq 4 | 5 | def initialize(amq, agent_timeout, identity, serializer) 6 | @amq = amq 7 | @agent_timeout = agent_timeout 8 | @identity = identity 9 | @serializer = serializer 10 | @nanites = {} 11 | @reaper = Reaper.new(agent_timeout) 12 | setup_queues 13 | end 14 | 15 | # determine which nanites should receive the given request 16 | def targets_for(request) 17 | return [request.target] if request.target 18 | __send__(request.selector, request.type).collect {|name, state| name } 19 | end 20 | 21 | # adds nanite to nanites map: key is nanite's identity 22 | # and value is a services/status pair implemented 23 | # as a hash 24 | def register(reg) 25 | nanites[reg.identity] = { :services => reg.services, :status => reg.status } 26 | reaper.timeout(reg.identity, agent_timeout + 1) { nanites.delete(reg.identity) } 27 | Nanite::Log.info("registered: #{reg.identity}, #{nanites[reg.identity]}") 28 | end 29 | 30 | def route(request, targets) 31 | EM.next_tick { targets.map { |target| publish(request, target) } } 32 | end 33 | 34 | def publish(request, target) 35 | amq.queue(target).publish(serializer.dump(request), :persistent => request.persistent) 36 | end 37 | 38 | protected 39 | 40 | # updates nanite information (last ping timestamps, status) 41 | # when heartbeat message is received 42 | def handle_ping(ping) 43 | if nanite = nanites[ping.identity] 44 | nanite[:status] = ping.status 45 | reaper.reset(ping.identity) 46 | else 47 | amq.queue(ping.identity).publish(serializer.dump(Advertise.new)) 48 | end 49 | end 50 | 51 | # returns least loaded nanite that provides given service 52 | def least_loaded(service) 53 | candidates = nanites_providing(service) 54 | return [] if candidates.empty? 55 | 56 | [candidates.min { |a,b| a[1][:status] <=> b[1][:status] }] 57 | end 58 | 59 | # returns all nanites that provide given service 60 | def all(service) 61 | nanites_providing(service) 62 | end 63 | 64 | # returns a random nanite 65 | def random(service) 66 | candidates = nanites_providing(service) 67 | return [] if candidates.empty? 68 | 69 | [candidates[rand(candidates.size)]] 70 | end 71 | 72 | # selects next nanite that provides given service 73 | # using round robin rotation 74 | def rr(service) 75 | @last ||= {} 76 | @last[service] ||= 0 77 | candidates = nanites_providing(service) 78 | return [] if candidates.empty? 79 | @last[service] = 0 if @last[service] >= candidates.size 80 | candidate = candidates[@last[service]] 81 | @last[service] += 1 82 | [candidate] 83 | end 84 | 85 | # returns all nanites that provide the given service 86 | def nanites_providing(service) 87 | nanites.find_all { |name, state| state[:services].include?(service) } 88 | end 89 | 90 | def setup_queues 91 | setup_heartbeat_queue 92 | setup_registration_queue 93 | end 94 | 95 | def setup_heartbeat_queue 96 | amq.queue("heartbeat-#{identity}", :exclusive => true).bind(amq.fanout('heartbeat', :durable => true)).subscribe do |ping| 97 | Nanite::Log.debug('got heartbeat') 98 | handle_ping(serializer.load(ping)) 99 | end 100 | end 101 | 102 | def setup_registration_queue 103 | amq.queue("registration-#{identity}", :exclusive => true).bind(amq.fanout('registration', :durable => true)).subscribe do |msg| 104 | Nanite::Log.debug('got registration') 105 | register(serializer.load(msg)) 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /examples/rabbitmqctl/actors/rabbit.rb: -------------------------------------------------------------------------------- 1 | class Rabbit 2 | include Nanite::Actor 3 | 4 | expose :add_user,:delete_user, :change_password, :list_users, 5 | :add_vhost, :delete_vhost, :list_vhosts, 6 | :map_user_vhost, :unmap_user_vhost, :list_user_vhosts, :list_vhost_users, 7 | :list_queues, :list_exchanges, :list_exchanges, :list_connections 8 | 9 | def list_queues(vhost) 10 | parse_list(rabbitmqctl(:list_queues, vhost.empty? ? "" : "-p #{vhost}")).first 11 | end 12 | 13 | def list_exchanges(vhost) 14 | parse_list(rabbitmqctl(:list_exchanges, vhost.empty? ? "" : "-p #{vhost}")).first 15 | end 16 | 17 | def list_bindings(vhost) 18 | parse_list(rabbitmqctl(:list_bindings, vhost.empty? ? "" : "-p #{vhost}")).first 19 | end 20 | 21 | def list_connections(payload) 22 | parse_list(rabbitmqctl(:list_connections)).first 23 | end 24 | 25 | def list_users(payload) 26 | parse_list(rabbitmqctl(:list_users)).first 27 | end 28 | 29 | def list_vhosts(payload) 30 | parse_list(rabbitmqctl(:list_vhosts)).first 31 | end 32 | 33 | def list_vhost_users(vhost) 34 | parse_list(rabbitmqctl(:list_vhost_users, vhost)).first 35 | end 36 | 37 | def list_user_vhosts(user) 38 | parse_list(rabbitmqctl(:list_user_vhosts, user)).first 39 | end 40 | 41 | def map_user_vhost(payload) 42 | if String === payload 43 | payload = JSON.parse(payload) 44 | end 45 | res = parse_list(rabbitmqctl(:map_user_vhost, payload['user'], payload['vhost'])) 46 | if res[1] 47 | "problem mapping user to vhost: #{payload['user']}:#{payload['vhost']} #{res[1]}" 48 | else 49 | "successfully mapped user to vhost: #{payload['user']}:#{payload['vhost']}" 50 | end 51 | end 52 | 53 | def unmap_user_vhost(payload) 54 | if String === payload 55 | payload = JSON.parse(payload) 56 | end 57 | res = parse_list(rabbitmqctl(:unmap_user_vhost, payload['user'], payload['vhost'])) 58 | if res[1] 59 | "problem unmapping user from vhost: #{payload['user']}:#{payload['vhost']} #{res[1]}" 60 | else 61 | "successfully unmapped user from vhost: #{payload['user']}:#{payload['vhost']}" 62 | end 63 | end 64 | 65 | def add_vhost(path) 66 | res = parse_list(rabbitmqctl(:add_vhost, path)) 67 | if res[1] 68 | "problem adding vhost: #{path} #{res[1]}" 69 | else 70 | "successfully added vhost: #{path}" 71 | end 72 | end 73 | 74 | def add_user(payload) 75 | if String === payload 76 | payload = JSON.parse(payload) 77 | end 78 | res = parse_list(rabbitmqctl(:add_user, payload['user'], payload['pass'])) 79 | if res[1] 80 | "problem adding user: #{payload['user']} #{res[1]}" 81 | else 82 | "successfully added user: #{payload['user']}" 83 | end 84 | end 85 | 86 | def change_password(payload) 87 | if String === payload 88 | payload = JSON.parse(payload) 89 | end 90 | res = parse_list(rabbitmqctl(:change_password, payload['user'], payload['pass'])) 91 | if res[1] 92 | "problem with change_password user: #{payload['user']} #{res[1]}" 93 | else 94 | "successfully changed password user: #{payload['user']}" 95 | end 96 | end 97 | 98 | def delete_user(payload) 99 | if String === payload 100 | payload = JSON.parse(payload) 101 | end 102 | res = parse_list(rabbitmqctl(:delete_user, payload['user'])) 103 | if res[1] 104 | "problem deleting user: #{payload['user']} #{res[1]}" 105 | else 106 | "successfully deleted user: #{payload['user']}" 107 | end 108 | end 109 | 110 | def delete_vhost(path) 111 | res = parse_list(rabbitmqctl(:delete_vhost, path)) 112 | if res[1] 113 | "problem deleting vhost: #{path} #{res[1]}" 114 | else 115 | "successfully deleted vhost: #{path}" 116 | end 117 | end 118 | 119 | def rabbitmqctl(*args) 120 | `rabbitmqctl #{args.join(' ')}` 121 | end 122 | 123 | def parse_list(out) 124 | res = [] 125 | error = nil 126 | out.each do |line| 127 | res << line.chomp unless line =~ /\.\.\./ 128 | error = $1 if line =~ /Error: (.*)/ 129 | end 130 | [res, error] 131 | end 132 | end -------------------------------------------------------------------------------- /spec/serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | require 'nanite' 3 | 4 | describe "Serializer:" do 5 | 6 | describe "Format" do 7 | 8 | it "supports JSON format" do 9 | [ :json, "json" ].each do |format| 10 | serializer = Nanite::Serializer.new(format) 11 | serializer.instance_eval { @serializers.first }.should == JSON 12 | end 13 | end 14 | 15 | it "supports Marshal format" do 16 | [ :marshal, "marshal" ].each do |format| 17 | serializer = Nanite::Serializer.new(format) 18 | serializer.instance_eval { @serializers.first }.should == Marshal 19 | end 20 | end 21 | 22 | it "supports YAML format" do 23 | [ :yaml, "yaml" ].each do |format| 24 | serializer = Nanite::Serializer.new(format) 25 | serializer.instance_eval { @serializers.first }.should == YAML 26 | end 27 | end 28 | 29 | it "should default to Marshal format if not specified" do 30 | serializer = Nanite::Serializer.new 31 | serializer.instance_eval { @serializers.first }.should == Marshal 32 | serializer = Nanite::Serializer.new(nil) 33 | serializer.instance_eval { @serializers.first }.should == Marshal 34 | end 35 | 36 | end 37 | 38 | describe "Serialization of Packet" do 39 | 40 | it "should try all three supported formats (JSON, Marshal, YAML)" do 41 | serialized_packet = mock("Packet") 42 | # Nanite::Serializer tries serializers in hash order which 43 | # is dependent on hash value of key and capacity 44 | # which is platform/version dependent 45 | serializers = {:json => JSON, :marshal => Marshal, :yaml => YAML}.values.clone 46 | puts serializers.inspect 47 | serializers[0].should_receive(:dump).with("hello").and_raise(StandardError) 48 | serializers[1].should_receive(:dump).with("hello").and_raise(StandardError) 49 | serializers[2].should_receive(:dump).with("hello").and_return(serialized_packet) 50 | 51 | serializer = Nanite::Serializer.new 52 | serializer.dump("hello") 53 | end 54 | 55 | it "should raise SerializationError if packet could not be serialized" do 56 | JSON.should_receive(:dump).with("hello").and_raise(StandardError) 57 | Marshal.should_receive(:dump).with("hello").and_raise(StandardError) 58 | YAML.should_receive(:dump).with("hello").and_raise(StandardError) 59 | 60 | serializer = Nanite::Serializer.new 61 | lambda { serializer.dump("hello") }.should raise_error(Nanite::Serializer::SerializationError) 62 | end 63 | 64 | it "should return serialized packet" do 65 | serialized_packet = mock("Packet") 66 | Marshal.should_receive(:dump).with("hello").and_return(serialized_packet) 67 | 68 | serializer = Nanite::Serializer.new(:marshal) 69 | serializer.dump("hello").should == serialized_packet 70 | end 71 | 72 | end 73 | 74 | describe "De-Serialization of Packet" do 75 | 76 | it "should try all three supported formats (JSON, Marshal, YAML)" do 77 | deserialized_packet = mock("Packet") 78 | # Nanite::Serializer tries serializers in hash order which 79 | # is dependent on hash value of key and capacity 80 | # which is platform/version dependent 81 | serializers = {:json => JSON, :marshal => Marshal, :yaml => YAML}.values.clone 82 | puts serializers.inspect 83 | serializers[0].should_receive(:load).with("olleh").and_raise(StandardError) 84 | serializers[1].should_receive(:load).with("olleh").and_raise(StandardError) 85 | serializers[2].should_receive(:load).with("olleh").and_return(deserialized_packet) 86 | 87 | serializer = Nanite::Serializer.new 88 | serializer.load("olleh").should == deserialized_packet 89 | end 90 | 91 | it "should raise SerializationError if packet could not be de-serialized" do 92 | JSON.should_receive(:load).with("olleh").and_raise(StandardError) 93 | Marshal.should_receive(:load).with("olleh").and_raise(StandardError) 94 | YAML.should_receive(:load).with("olleh").and_raise(StandardError) 95 | 96 | serializer = Nanite::Serializer.new 97 | lambda { serializer.load("olleh") }.should raise_error(Nanite::Serializer::SerializationError) 98 | end 99 | 100 | it "should return de-serialized packet" do 101 | deserialized_packet = mock("Packet") 102 | Marshal.should_receive(:load).with("olleh").and_return(deserialized_packet) 103 | 104 | serializer = Nanite::Serializer.new(:marshal) 105 | serializer.load("olleh").should == deserialized_packet 106 | end 107 | 108 | end 109 | 110 | end 111 | -------------------------------------------------------------------------------- /lib/nanite/streaming.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | # Nanite actors can transfer files to each other. 3 | # 4 | # ==== Options 5 | # 6 | # filename : you guessed it, name of the file! 7 | # domain : part of the routing key used to locate receiver(s) 8 | # destination : is a name of the file as it gonna be stored at the destination 9 | # meta : 10 | # 11 | # File streaming is done in chunks. When file streaming starts, 12 | # Nanite::FileStart packet is sent, followed by one or more (usually more ;)) 13 | # Nanite::FileChunk packets each 16384 (16K) in size. Once file streaming is done, 14 | # Nanite::FileEnd packet is sent. 15 | # 16 | # 16K is a packet size because on certain UNIX-like operating systems, you cannot read/write 17 | # more than that in one operation via socket. 18 | # 19 | # ==== Domains 20 | # 21 | # Streaming happens using a topic exchange called 'file broadcast', with keys 22 | # formatted as "nanite.filepeer.DOMAIN". Domain variable in the key lets senders and 23 | # receivers find each other in the cluster. Default domain is 'global'. 24 | # 25 | # Domains also serve as a way to register a callback Nanite agent executes once file 26 | # streaming is completed. If a callback with name of domain is registered, it is called. 27 | # 28 | # Callbacks are registered by passing a block to subscribe_to_files method. 29 | module FileStreaming 30 | def broadcast_file(filename, options = {}) 31 | if File.exist?(filename) 32 | File.open(filename, 'rb') do |file| 33 | broadcast_data(filename, file, options) 34 | end 35 | else 36 | return "file not found" 37 | end 38 | end 39 | 40 | def broadcast_data(filename, io, options = {}) 41 | domain = options[:domain] || 'global' 42 | filename = File.basename(filename) 43 | dest = options[:destination] || filename 44 | sent = 0 45 | 46 | begin 47 | file_push = Nanite::FileStart.new(filename, dest) 48 | amq.topic('file broadcast').publish(dump_packet(file_push), :key => "nanite.filepeer.#{domain}") 49 | res = Nanite::FileChunk.new(file_push.token) 50 | while chunk = io.read(16384) 51 | res.chunk = chunk 52 | amq.topic('file broadcast').publish(dump_packet(res), :key => "nanite.filepeer.#{domain}") 53 | sent += chunk.length 54 | end 55 | fend = Nanite::FileEnd.new(file_push.token, options[:meta]) 56 | amq.topic('file broadcast').publish(dump_packet(fend), :key => "nanite.filepeer.#{domain}") 57 | "" 58 | ensure 59 | io.close 60 | end 61 | 62 | sent 63 | end 64 | 65 | # FileState represents a file download in progress. 66 | # It incapsulates the following information: 67 | # 68 | # * unique operation token 69 | # * domain (namespace for file streaming operations) 70 | # * file IO chunks are written to on receiver's side 71 | class FileState 72 | 73 | def initialize(token, dest, domain, write, blk) 74 | @token = token 75 | @cb = blk 76 | @domain = domain 77 | @write = write 78 | 79 | if write 80 | @filename = File.join(Nanite.agent.file_root, dest) 81 | @dest = File.open(@filename, 'wb') 82 | else 83 | @dest = dest 84 | end 85 | 86 | @data = "" 87 | end 88 | 89 | def handle_packet(packet) 90 | case packet 91 | when Nanite::FileChunk 92 | Nanite::Log.debug "written chunk to #{@dest.inspect}" 93 | @data << packet.chunk 94 | 95 | if @write 96 | @dest.write(packet.chunk) 97 | end 98 | when Nanite::FileEnd 99 | Nanite::Log.debug "#{@dest.inspect} receiving is completed" 100 | if @write 101 | @dest.close 102 | end 103 | 104 | @cb.call(@data, @dest, packet.meta) 105 | end 106 | end 107 | 108 | end 109 | 110 | def subscribe_to_files(domain='global', write=false, &blk) 111 | Nanite::Log.info "subscribing to file broadcasts for #{domain}" 112 | @files ||= {} 113 | amq.queue("files#{domain}").bind(amq.topic('file broadcast'), :key => "nanite.filepeer.#{domain}").subscribe do |packet| 114 | case msg = load_packet(packet) 115 | when FileStart 116 | @files[msg.token] = FileState.new(msg.token, msg.dest, domain, write, blk) 117 | when FileChunk, FileEnd 118 | if file = @files[msg.token] 119 | file.handle_packet(msg) 120 | end 121 | end 122 | end 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/nanite/admin.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | module Nanite 4 | # This is a Rack app for nanite-admin. You need to have an async capable 5 | # version of Thin installed for this to work. See bin/nanite-admin for install 6 | # instructions. 7 | class Admin 8 | def initialize(mapper) 9 | @mapper = mapper 10 | end 11 | 12 | AsyncResponse = [-1, {}, []].freeze 13 | 14 | def call(env) 15 | req = Rack::Request.new(env) 16 | if cmd = req.params['command'] 17 | @command = cmd 18 | @selection = req.params['type'] if req.params['type'] 19 | 20 | options = {} 21 | case @selection 22 | when 'least_loaded', 'random', 'all', 'rr' 23 | options[:selector] = @selection 24 | else 25 | options[:target] = @selection 26 | end 27 | 28 | @mapper.request(cmd, req.params['payload'], options) do |response| 29 | env['async.callback'].call [200, {'Content-Type' => 'text/html'}, [layout(ul(response))]] 30 | end 31 | AsyncResponse 32 | else 33 | [200, {'Content-Type' => 'text/html'}, layout] 34 | end 35 | end 36 | 37 | def services 38 | buf = "" 43 | buf 44 | end 45 | 46 | def ul(hash) 47 | buf = "" 52 | buf 53 | end 54 | 55 | def layout(content=nil) 56 | %Q{ 57 | 58 | 59 | 60 | 61 | 62 | 63 | Nanite Control Tower 64 | 65 | 66 | 67 | 70 | 71 | 79 | 80 | 95 | 96 | 97 | 98 | 99 | 102 | 103 |

#{@mapper.options[:vhost]}

104 |
105 |
106 | 107 | 108 | 109 | 116 | 117 | 118 | #{services} 119 | 120 | 121 | 122 | 123 | 124 |
125 | 126 | #{"

Responses

" if content} 127 | #{content} 128 |
129 | 130 |

Running nanites

131 |
132 | #{"No nanites online." if @mapper.cluster.nanites.size == 0} 133 |
    134 | #{@mapper.cluster.nanites.map {|k,v| "
  • identity : #{k}
    load : #{v[:status]}
    services : #{v[:services].inspect}
  • " }.join} 135 |
136 |
137 | 142 | 143 | 144 | } 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /spec/packet_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | require 'nanite/packets' 3 | require 'json' 4 | 5 | describe "Packet: Base class" do 6 | before(:all) do 7 | class TestPacket < Nanite::Packet 8 | @@cls_attr = "ignore" 9 | def initialize(attr1) 10 | @attr1 = attr1 11 | end 12 | end 13 | end 14 | 15 | it "should be an abstract class" do 16 | lambda { Nanite::Packet.new }.should raise_error(NotImplementedError, "Nanite::Packet is an abstract class.") 17 | end 18 | 19 | it "should know how to dump itself to JSON" do 20 | packet = TestPacket.new(1) 21 | packet.should respond_to(:to_json) 22 | end 23 | 24 | it "should dump the class name in 'json_class' JSON key" do 25 | packet = TestPacket.new(42) 26 | packet.to_json().should =~ /\"json_class\":\"TestPacket\"/ 27 | end 28 | 29 | it "should dump instance variables in 'data' JSON key" do 30 | packet = TestPacket.new(188) 31 | packet.to_json().should =~ /\"data\":\{\"attr1\":188\}/ 32 | end 33 | 34 | it "should not dump class variables" do 35 | packet = TestPacket.new(382) 36 | packet.to_json().should_not =~ /cls_attr/ 37 | end 38 | 39 | it "should store instance variables in 'data' JSON key as JSON object" do 40 | packet = TestPacket.new(382) 41 | packet.to_json().should =~ /\"data\":\{[\w:"]+\}/ 42 | end 43 | 44 | it "should remove '@' from instance variables" do 45 | packet = TestPacket.new(2) 46 | packet.to_json().should_not =~ /@attr1/ 47 | packet.to_json().should =~ /attr1/ 48 | end 49 | end 50 | 51 | describe "Packet: FileStart" do 52 | it "should dump/load as JSON objects" do 53 | packet = Nanite::FileStart.new('foo.txt', 'somewhere/foo.txt', '0xdeadbeef') 54 | packet2 = JSON.parse(packet.to_json) 55 | packet.filename.should == packet2.filename 56 | packet.dest.should == packet2.dest 57 | packet.token.should == packet2.token 58 | end 59 | 60 | it "should dump/load as Marshalled ruby objects" do 61 | packet = Nanite::FileStart.new('foo.txt', 'somewhere/foo.txt', '0xdeadbeef') 62 | packet2 = Marshal.load(Marshal.dump(packet)) 63 | packet.filename.should == packet2.filename 64 | packet.dest.should == packet2.dest 65 | packet.token.should == packet2.token 66 | end 67 | end 68 | 69 | describe "Packet: FileEnd" do 70 | it "should dump/load as JSON objects" do 71 | packet = Nanite::FileEnd.new('0xdeadbeef', 'metadata') 72 | packet2 = JSON.parse(packet.to_json) 73 | packet.meta.should == packet2.meta 74 | packet.token.should == packet2.token 75 | end 76 | 77 | it "should dump/load as Marshalled ruby objects" do 78 | packet = Nanite::FileEnd.new('0xdeadbeef', 'metadata') 79 | packet2 = Marshal.load(Marshal.dump(packet)) 80 | packet.meta.should == packet2.meta 81 | packet.token.should == packet2.token 82 | end 83 | end 84 | 85 | describe "Packet: FileChunk" do 86 | it "should dump/load as JSON objects" do 87 | packet = Nanite::FileChunk.new('chunk','0xdeadbeef') 88 | packet2 = JSON.parse(packet.to_json) 89 | packet.chunk.should == packet2.chunk 90 | packet.token.should == packet2.token 91 | end 92 | 93 | it "should dump/load as Marshalled ruby objects" do 94 | packet = Nanite::FileChunk.new('chunk','0xdeadbeef') 95 | packet2 = Marshal.load(Marshal.dump(packet)) 96 | packet.chunk.should == packet2.chunk 97 | packet.token.should == packet2.token 98 | end 99 | end 100 | 101 | describe "Packet: Request" do 102 | it "should dump/load as JSON objects" do 103 | packet = Nanite::Request.new('/some/foo', 'payload', :from => 'from', :token => '0xdeadbeef', :reply_to => 'reply_to') 104 | packet2 = JSON.parse(packet.to_json) 105 | packet.type.should == packet2.type 106 | packet.payload.should == packet2.payload 107 | packet.from.should == packet2.from 108 | packet.token.should == packet2.token 109 | packet.reply_to.should == packet2.reply_to 110 | end 111 | 112 | it "should dump/load as Marshalled ruby objects" do 113 | packet = Nanite::Request.new('/some/foo', 'payload', :from => 'from', :token => '0xdeadbeef', :reply_to => 'reply_to') 114 | packet2 = Marshal.load(Marshal.dump(packet)) 115 | packet.type.should == packet2.type 116 | packet.payload.should == packet2.payload 117 | packet.from.should == packet2.from 118 | packet.token.should == packet2.token 119 | packet.reply_to.should == packet2.reply_to 120 | end 121 | end 122 | 123 | 124 | describe "Packet: Result" do 125 | it "should dump/load as JSON objects" do 126 | packet = Nanite::Result.new('0xdeadbeef', 'to', 'results', 'from') 127 | packet2 = JSON.parse(packet.to_json) 128 | packet.token.should == packet2.token 129 | packet.to.should == packet2.to 130 | packet.results.should == packet2.results 131 | packet.from.should == packet2.from 132 | end 133 | 134 | it "should dump/load as Marshalled ruby objects" do 135 | packet = Nanite::Result.new('0xdeadbeef', 'to', 'results', 'from') 136 | packet2 = Marshal.load(Marshal.dump(packet)) 137 | packet.token.should == packet2.token 138 | packet.to.should == packet2.to 139 | packet.results.should == packet2.results 140 | packet.from.should == packet2.from 141 | end 142 | end 143 | 144 | describe "Packet: Register" do 145 | it "should dump/load as JSON objects" do 146 | packet = Nanite::Register.new('0xdeadbeef', ['/foo/bar', '/nik/qux'], 0.8) 147 | packet2 = JSON.parse(packet.to_json) 148 | packet.identity.should == packet2.identity 149 | packet.services.should == packet2.services 150 | packet.status.should == packet2.status 151 | end 152 | 153 | it "should dump/load as Marshalled ruby objects" do 154 | packet = Nanite::Register.new('0xdeadbeef', ['/foo/bar', '/nik/qux'], 0.8) 155 | packet2 = Marshal.load(Marshal.dump(packet)) 156 | packet.identity.should == packet2.identity 157 | packet.services.should == packet2.services 158 | packet.status.should == packet2.status 159 | end 160 | end 161 | 162 | describe "Packet: Ping" do 163 | it "should dump/load as JSON objects" do 164 | packet = Nanite::Ping.new('0xdeadbeef', 0.8) 165 | packet2 = JSON.parse(packet.to_json) 166 | packet.identity.should == packet2.identity 167 | packet.status.should == packet2.status 168 | end 169 | 170 | it "should dump/load as Marshalled ruby objects" do 171 | packet = Nanite::Ping.new('0xdeadbeef', 0.8) 172 | packet2 = Marshal.load(Marshal.dump(packet)) 173 | packet.identity.should == packet2.identity 174 | packet.status.should == packet2.status 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /lib/nanite/agent.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | class Agent 3 | include AMQPHelper 4 | include FileStreaming 5 | include ConsoleHelper 6 | include DaemonizeHelper 7 | 8 | attr_reader :identity, :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_path = false 77 | if @options[:daemonize] 78 | log_path = (@options[:log_dir] || @options[:root] || Dir.pwd) 79 | end 80 | Log.init(@identity, log_path) 81 | Log.log_level = @options[:log_level] || :info 82 | @serializer = Serializer.new(@options[:format]) 83 | @status_proc = lambda { parse_uptime(`uptime`) rescue 'no status' } 84 | daemonize if @options[:daemonize] 85 | @amq = start_amqp(@options) 86 | @registry = ActorRegistry.new 87 | @dispatcher = Dispatcher.new(@amq, @registry, @serializer, @identity, @options) 88 | load_actors 89 | setup_queue 90 | advertise_services 91 | setup_heartbeat 92 | start_console if @options[:console] && !@options[:daemonize] 93 | end 94 | 95 | def register(actor, prefix = nil) 96 | registry.register(actor, prefix) 97 | end 98 | 99 | protected 100 | 101 | def set_configuration(opts) 102 | @options = DEFAULT_OPTIONS.clone 103 | root = opts[:root] || @options[:root] 104 | custom_config = if root 105 | file = File.expand_path(File.join(root, 'config.yml')) 106 | File.exists?(file) ? (YAML.load(IO.read(file)) || {}) : {} 107 | else 108 | {} 109 | end 110 | opts.delete(:identity) unless opts[:identity] 111 | @options.update(custom_config.merge(opts)) 112 | @options[:file_root] ||= File.join(@options[:root], 'files') 113 | return @identity = "nanite-#{@options[:identity]}" if @options[:identity] 114 | token = Identity.generate 115 | @identity = "nanite-#{token}" 116 | File.open(File.expand_path(File.join(@options[:root], 'config.yml')), 'w') do |fd| 117 | fd.write(YAML.dump(custom_config.merge(:identity => token))) 118 | end 119 | end 120 | 121 | def load_actors 122 | return unless options[:root] 123 | Dir["#{options[:root]}/actors/*.rb"].each do |actor| 124 | Nanite::Log.info("loading actor: #{actor}") 125 | require actor 126 | end 127 | init_path = File.join(options[:root], 'init.rb') 128 | instance_eval(File.read(init_path), init_path) if File.exist?(init_path) 129 | end 130 | 131 | def receive(packet) 132 | packet = serializer.load(packet) 133 | case packet 134 | when Advertise 135 | Nanite::Log.debug("handling Advertise: #{packet}") 136 | advertise_services 137 | when Request, Push 138 | Nanite::Log.debug("handling Request: #{packet}") 139 | dispatcher.dispatch(packet) 140 | end 141 | end 142 | 143 | def setup_queue 144 | amq.queue(identity, :durable => true).subscribe(:ack => true) do |info, msg| 145 | info.ack 146 | receive(msg) 147 | end 148 | end 149 | 150 | def setup_heartbeat 151 | EM.add_periodic_timer(options[:ping_time]) do 152 | amq.fanout('heartbeat', :no_declare => options[:secure]).publish(serializer.dump(Ping.new(identity, status_proc.call))) 153 | end 154 | end 155 | 156 | def advertise_services 157 | Nanite::Log.debug("advertise_services: #{registry.services.inspect}") 158 | amq.fanout('registration', :no_declare => options[:secure]).publish(serializer.dump(Register.new(identity, registry.services, status_proc.call))) 159 | end 160 | 161 | def parse_uptime(up) 162 | if up =~ /load averages?: (.*)/ 163 | a,b,c = $1.split(/\s+|,\s+/) 164 | (a.to_f + b.to_f + c.to_f) / 3 165 | end 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /spec/agent_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | require 'nanite' 3 | 4 | describe "Agent:" do 5 | 6 | describe "Default Option" do 7 | 8 | before(:all) do 9 | EM.stub!(:add_periodic_timer) 10 | AMQP.stub!(:connect) 11 | @amq = mock("AMQueue", :queue => mock("queue", :subscribe => {}), :fanout => mock("fanout", :publish => nil)) 12 | MQ.stub!(:new).and_return(@amq) 13 | @agent = Nanite::Agent.start 14 | end 15 | 16 | it "for daemonize is false" do 17 | @agent.options.should include(:daemonize) 18 | @agent.options[:daemonize].should == false 19 | end 20 | 21 | it "for format is marshal" do 22 | @agent.options.should include(:format) 23 | @agent.options[:format].should == :marshal 24 | end 25 | 26 | it "for console is false" do 27 | @agent.options.should include(:console) 28 | @agent.options[:console].should == false 29 | end 30 | 31 | it "for user is nanite" do 32 | @agent.options.should include(:user) 33 | @agent.options[:user].should == "nanite" 34 | end 35 | 36 | it "for pass(word) is testing" do 37 | @agent.options.should include(:pass) 38 | @agent.options[:pass].should == "testing" 39 | end 40 | 41 | it "for secure is false" do 42 | @agent.options.should include(:secure) 43 | @agent.options[:secure].should == false 44 | end 45 | 46 | it "for host is 0.0.0.0" do 47 | @agent.options.should include(:host) 48 | @agent.options[:host].should == "0.0.0.0" 49 | end 50 | 51 | it "for log_level is info" do 52 | @agent.options.should include(:log_level) 53 | @agent.options[:log_level].should == :info 54 | end 55 | 56 | it "for vhost is /nanite" do 57 | @agent.options.should include(:vhost) 58 | @agent.options[:vhost].should == "/nanite" 59 | end 60 | 61 | it "for ping_time is 15" do 62 | @agent.options.should include(:ping_time) 63 | @agent.options[:ping_time].should == 15 64 | end 65 | 66 | it "for default_services is []" do 67 | @agent.options.should include(:default_services) 68 | @agent.options[:default_services].should == [] 69 | end 70 | 71 | it "for root is #{File.expand_path(File.join(File.dirname(__FILE__), '..'))}" do 72 | @agent.options.should include(:root) 73 | @agent.options[:root].should == File.expand_path(File.join(File.dirname(__FILE__), '..')) 74 | end 75 | 76 | it "for file_root is #{File.expand_path(File.join(File.dirname(__FILE__), '..', 'files'))}" do 77 | @agent.options.should include(:file_root) 78 | @agent.options[:file_root].should == File.expand_path(File.join(File.dirname(__FILE__), '..', 'files')) 79 | end 80 | 81 | end 82 | 83 | describe "Options from config.yml" do 84 | 85 | before(:all) do 86 | @agent = Nanite::Agent.start 87 | end 88 | 89 | end 90 | 91 | describe "Passed in Options" do 92 | 93 | before(:each) do 94 | EM.stub!(:add_periodic_timer) 95 | AMQP.stub!(:connect) 96 | @amq = mock("AMQueue", :queue => mock("queue", :subscribe => {}), :fanout => mock("fanout", :publish => nil)) 97 | MQ.stub!(:new).and_return(@amq) 98 | end 99 | 100 | # TODO figure out how to stub call to daemonize 101 | # it "for daemonize should override default (false)" do 102 | # agent = Nanite::Agent.start(:daemonize => true) 103 | # agent.options.should include(:daemonize) 104 | # agent.options[:daemonize].should == true 105 | # end 106 | 107 | it "for format should override default (marshal)" do 108 | agent = Nanite::Agent.start(:format => :json) 109 | agent.options.should include(:format) 110 | agent.options[:format].should == :json 111 | end 112 | 113 | # TODO figure out how to avoid console output 114 | # it "for console should override default (false)" do 115 | # agent = Nanite::Agent.start(:console => true) 116 | # agent.options.should include(:console) 117 | # agent.options[:console].should == true 118 | # end 119 | 120 | it "for user should override default (nanite)" do 121 | agent = Nanite::Agent.start(:user => "me") 122 | agent.options.should include(:user) 123 | agent.options[:user].should == "me" 124 | end 125 | 126 | it "for pass(word) should override default (testing)" do 127 | agent = Nanite::Agent.start(:pass => "secret") 128 | agent.options.should include(:pass) 129 | agent.options[:pass].should == "secret" 130 | end 131 | 132 | it "for secure should override default (false)" do 133 | agent = Nanite::Agent.start(:secure => true) 134 | agent.options.should include(:secure) 135 | agent.options[:secure].should == true 136 | end 137 | 138 | it "for host should override default (0.0.0.0)" do 139 | agent = Nanite::Agent.start(:host => "127.0.0.1") 140 | agent.options.should include(:host) 141 | agent.options[:host].should == "127.0.0.1" 142 | end 143 | 144 | it "for log_level should override default (info)" do 145 | agent = Nanite::Agent.start(:log_level => :debug) 146 | agent.options.should include(:log_level) 147 | agent.options[:log_level].should == :debug 148 | end 149 | 150 | it "for vhost should override default (/nanite)" do 151 | agent = Nanite::Agent.start(:vhost => "/virtual_host") 152 | agent.options.should include(:vhost) 153 | agent.options[:vhost].should == "/virtual_host" 154 | end 155 | 156 | it "for ping_time should override default (15)" do 157 | agent = Nanite::Agent.start(:ping_time => 5) 158 | agent.options.should include(:ping_time) 159 | agent.options[:ping_time].should == 5 160 | end 161 | 162 | it "for default_services should override default ([])" do 163 | agent = Nanite::Agent.start(:default_services => [:test]) 164 | agent.options.should include(:default_services) 165 | agent.options[:default_services].should == [:test] 166 | end 167 | 168 | it "for root should override default (#{File.expand_path(File.join(File.dirname(__FILE__), '..'))})" do 169 | agent = Nanite::Agent.start(:root => File.expand_path(File.dirname(__FILE__))) 170 | agent.options.should include(:root) 171 | agent.options[:root].should == File.expand_path(File.dirname(__FILE__)) 172 | end 173 | 174 | it "for file_root should override default (#{File.expand_path(File.join(File.dirname(__FILE__), '..', 'files'))})" do 175 | agent = Nanite::Agent.start(:file_root => File.expand_path(File.dirname(__FILE__))) 176 | agent.options.should include(:file_root) 177 | agent.options[:file_root].should == File.expand_path(File.dirname(__FILE__)) 178 | end 179 | 180 | end 181 | 182 | end 183 | -------------------------------------------------------------------------------- /lib/nanite/packets.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | # Base class for all Nanite packets, 3 | # knows how to dump itself to JSON 4 | class Packet 5 | def initialize 6 | raise NotImplementedError.new("#{self.class.name} is an abstract class.") 7 | end 8 | def to_json(*a) 9 | { 10 | 'json_class' => self.class.name, 11 | 'data' => instance_variables.inject({}) {|m,ivar| m[ivar.sub(/@/,'')] = instance_variable_get(ivar); m } 12 | }.to_json(*a) 13 | end 14 | end 15 | 16 | # packet that means start of a file transfer 17 | # operation 18 | class FileStart < Packet 19 | attr_accessor :filename, :token, :dest 20 | def initialize(filename, dest, token) 21 | @filename = filename 22 | @dest = dest 23 | @token = token 24 | end 25 | 26 | def self.json_create(o) 27 | i = o['data'] 28 | new(i['filename'], i['dest'], i['token']) 29 | end 30 | end 31 | 32 | # packet that means end of a file transfer 33 | # operation 34 | class FileEnd < Packet 35 | attr_accessor :token, :meta 36 | def initialize(token, meta) 37 | @token = token 38 | @meta = meta 39 | end 40 | 41 | def self.json_create(o) 42 | i = o['data'] 43 | new(i['token'], i['meta']) 44 | end 45 | end 46 | 47 | # packet that carries data chunks during a file transfer 48 | class FileChunk < Packet 49 | attr_accessor :chunk, :token 50 | def initialize(token, chunk=nil) 51 | @chunk = chunk 52 | @token = token 53 | end 54 | def self.json_create(o) 55 | i = o['data'] 56 | new(i['token'], i['chunk']) 57 | end 58 | end 59 | 60 | # packet that means a work request from mapper 61 | # to actor node 62 | # 63 | # type is a service name 64 | # payload is arbitrary data that is transferred from mapper to actor 65 | # 66 | # Options: 67 | # from is sender identity 68 | # token is a generated request id that mapper uses to identify replies 69 | # reply_to is identity of the node actor replies to, usually a mapper itself 70 | # selector is the selector used to route the request 71 | # target is the target nanite for the request 72 | # persistent signifies if this request should be saved to persistent storage by the AMQP broker 73 | class Request < Packet 74 | attr_accessor :from, :payload, :type, :token, :reply_to, :selector, :target, :persistent 75 | DEFAULT_OPTIONS = {:selector => :least_loaded} 76 | def initialize(type, payload, opts={}) 77 | opts = DEFAULT_OPTIONS.merge(opts) 78 | @type = type 79 | @payload = payload 80 | @from = opts[:from] 81 | @token = opts[:token] 82 | @reply_to = opts[:reply_to] 83 | @selector = opts[:selector] 84 | @target = opts[:target] 85 | @persistent = opts[:persistent] 86 | end 87 | def self.json_create(o) 88 | i = o['data'] 89 | new(i['type'], i['payload'], {:from => i['from'], :token => i['token'], :reply_to => i['reply_to'], :selector => i['selector'], 90 | :target => i['target'], :persistent => i['persistent']}) 91 | end 92 | end 93 | 94 | # packet that means a work push from mapper 95 | # to actor node 96 | # 97 | # type is a service name 98 | # payload is arbitrary data that is transferred from mapper to actor 99 | # 100 | # Options: 101 | # from is sender identity 102 | # token is a generated request id that mapper uses to identify replies 103 | # selector is the selector used to route the request 104 | # target is the target nanite for the request 105 | # persistent signifies if this request should be saved to persistent storage by the AMQP broker 106 | class Push < Packet 107 | attr_accessor :from, :payload, :type, :token, :selector, :target, :persistent 108 | DEFAULT_OPTIONS = {:selector => :least_loaded} 109 | def initialize(type, payload, opts={}) 110 | opts = DEFAULT_OPTIONS.merge(opts) 111 | @type = type 112 | @payload = payload 113 | @from = opts[:from] 114 | @token = opts[:token] 115 | @selector = opts[:selector] 116 | @target = opts[:target] 117 | @persistent = opts[:persistent] 118 | end 119 | def self.json_create(o) 120 | i = o['data'] 121 | new(i['type'], i['payload'], {:from => i['from'], :token => i['token'], :selector => i['selector'], 122 | :target => i['target'], :persistent => i['persistent']}) 123 | end 124 | end 125 | 126 | # packet that means a work result notification sent from actor to mapper 127 | # 128 | # from is sender identity 129 | # results is arbitrary data that is transferred from actor, a result of actor's work 130 | # token is a generated request id that mapper uses to identify replies 131 | # to is identity of the node result should be delivered to 132 | class Result < Packet 133 | attr_accessor :token, :results, :to, :from 134 | def initialize(token, to, results, from) 135 | @token = token 136 | @to = to 137 | @from = from 138 | @results = results 139 | end 140 | def self.json_create(o) 141 | i = o['data'] 142 | new(i['token'], i['to'], i['results'], i['from']) 143 | end 144 | end 145 | 146 | # packet that means an availability notification sent from actor to mapper 147 | # 148 | # from is sender identity 149 | # services is a list of services provided by the node 150 | # status is a load of the node by default, but may be any criteria 151 | # agent may use to report it's availability, load, etc 152 | class Register < Packet 153 | attr_accessor :identity, :services, :status 154 | def initialize(identity, services, status) 155 | @status = status 156 | @identity = identity 157 | @services = services 158 | end 159 | def self.json_create(o) 160 | i = o['data'] 161 | new(i['identity'], i['services'], i['status']) 162 | end 163 | end 164 | 165 | # heartbeat packet 166 | # 167 | # identity is sender's identity 168 | # status is sender's status (see Register packet documentation) 169 | class Ping < Packet 170 | attr_accessor :identity, :status 171 | def initialize(identity, status) 172 | @status = status 173 | @identity = identity 174 | end 175 | def self.json_create(o) 176 | i = o['data'] 177 | new(i['identity'], i['status']) 178 | end 179 | end 180 | 181 | # packet that is sent by workers to the mapper 182 | # when worker initially comes online to advertise 183 | # it's services 184 | class Advertise < Packet 185 | def initialize 186 | end 187 | def self.json_create(o) 188 | new 189 | end 190 | end 191 | end 192 | 193 | -------------------------------------------------------------------------------- /spec/cluster_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | require 'nanite' 3 | 4 | describe Nanite::Cluster do 5 | 6 | describe "Intialization" do 7 | 8 | before(:each) do 9 | @fanout = mock("fanout") 10 | @binding = mock("binding", :subscribe => true) 11 | @queue = mock("queue", :bind => @binding) 12 | @amq = mock("AMQueue", :queue => @queue, :fanout => @fanout) 13 | @serializer = mock("Serializer") 14 | @reaper = mock("Reaper") 15 | Nanite::Reaper.stub!(:new).and_return(@reaper) 16 | end 17 | 18 | describe "of Heartbeat (Queue)" do 19 | 20 | it "should setup the heartbeat (queue) for id" do 21 | @amq.should_receive(:queue).with("heartbeat-the_identity", anything()).and_return(@queue) 22 | cluster = Nanite::Cluster.new(@amq, 10, "the_identity", @serializer) 23 | end 24 | 25 | it "should make the heartbeat (queue) exclusive" do 26 | @amq.should_receive(:queue).with("heartbeat-the_identity", { :exclusive => true }).and_return(@queue) 27 | cluster = Nanite::Cluster.new(@amq, 10, "the_identity", @serializer) 28 | end 29 | 30 | it "should bind the heartbeat (queue) to 'heartbeat' fanout" do 31 | @amq.should_receive(:fanout).with("heartbeat", { :durable => true }).and_return(@fanout) 32 | @queue.should_receive(:bind).with(@fanout).and_return(@binding) 33 | cluster = Nanite::Cluster.new(@amq, 10, "the_identity", @serializer) 34 | end 35 | 36 | end # of Heartbeat (Queue) 37 | 38 | 39 | describe "of Registration (Queue)" do 40 | 41 | it "should setup the registration (queue) for id" do 42 | @amq.should_receive(:queue).with("registration-the_identity", anything()).and_return(@queue) 43 | cluster = Nanite::Cluster.new(@amq, 10, "the_identity", @serializer) 44 | end 45 | 46 | it "should make the registration (queue) exclusive" do 47 | @amq.should_receive(:queue).with("registration-the_identity", { :exclusive => true }).and_return(@queue) 48 | cluster = Nanite::Cluster.new(@amq, 10, "the_identity", @serializer) 49 | end 50 | 51 | it "should bind the registration (queue) to 'registration' fanout" do 52 | @amq.should_receive(:fanout).with("registration", { :durable => true }).and_return(@fanout) 53 | @queue.should_receive(:bind).with(@fanout).and_return(@binding) 54 | cluster = Nanite::Cluster.new(@amq, 10, "the_identity", @serializer) 55 | end 56 | 57 | end # of Registration (Queue) 58 | 59 | 60 | describe "Reaper" do 61 | 62 | it "should be created" do 63 | Nanite::Reaper.should_receive(:new).with(anything()).and_return(@reaper) 64 | cluster = Nanite::Cluster.new(@amq, 32, "the_identity", @serializer) 65 | end 66 | 67 | it "should use the agent timeout" do 68 | Nanite::Reaper.should_receive(:new).with(443).and_return(@reaper) 69 | cluster = Nanite::Cluster.new(@amq, 443, "the_identity", @serializer) 70 | end 71 | 72 | end # Reaper 73 | 74 | end # Intialization 75 | 76 | 77 | describe "Target Selection" do 78 | 79 | before(:each) do 80 | @fanout = mock("fanout") 81 | @binding = mock("binding", :subscribe => true) 82 | @queue = mock("queue", :bind => @binding) 83 | @amq = mock("AMQueue", :queue => @queue, :fanout => @fanout) 84 | @serializer = mock("Serializer") 85 | @reaper = mock("Reaper") 86 | Nanite::Reaper.stub!(:new).and_return(@reaper) 87 | @cluster = Nanite::Cluster.new(@amq, 32, "the_identity", @serializer) 88 | end 89 | 90 | it "should return array containing targets for request" do 91 | target = mock("Supplied Target") 92 | request = mock("Request", :target => target) 93 | @cluster.targets_for(request).should be_instance_of(Array) 94 | end 95 | 96 | it "should use target from request" do 97 | target = mock("Supplied Target") 98 | request = mock("Request", :target => target) 99 | @cluster.targets_for(request).should == [target] 100 | end 101 | 102 | it "should use targets choosen by least loaded selector (:least_loaded)" do 103 | targets = { "target 3" => 3 } 104 | request = mock("Request", :target => nil, :selector => :least_loaded, :type => "service") 105 | @cluster.should_receive(:least_loaded).with("service").and_return(targets) 106 | @cluster.targets_for(request).should == ["target 3"] 107 | end 108 | 109 | it "should use targets choosen by all selector (:all)" do 110 | targets = { "target 1" => 1, "target 2" => 2, "target 3" => 3 } 111 | request = mock("Request", :target => nil, :selector => :all, :type => "service") 112 | @cluster.should_receive(:all).with("service").and_return(targets) 113 | @cluster.targets_for(request).should == ["target 1", "target 2", "target 3"] 114 | end 115 | 116 | it "should use targets choosen by random selector (:random)" do 117 | targets = { "target 3" => 3 } 118 | request = mock("Request", :target => nil, :selector => :random, :type => "service") 119 | @cluster.should_receive(:random).with("service").and_return(targets) 120 | @cluster.targets_for(request).should == ["target 3"] 121 | end 122 | 123 | it "should use targets choosen by round-robin selector (:rr)" do 124 | targets = { "target 2" => 2 } 125 | request = mock("Request", :target => nil, :selector => :rr, :type => "service") 126 | @cluster.should_receive(:rr).with("service").and_return(targets) 127 | @cluster.targets_for(request).should == ["target 2"] 128 | end 129 | 130 | end # Target Selection 131 | 132 | 133 | describe "Nanite Registration" do 134 | 135 | before(:each) do 136 | @fanout = mock("fanout") 137 | @binding = mock("binding", :subscribe => true) 138 | @queue = mock("queue", :bind => @binding) 139 | @amq = mock("AMQueue", :queue => @queue, :fanout => @fanout) 140 | @serializer = mock("Serializer") 141 | @reaper = mock("Reaper", :timeout => true) 142 | Nanite::Reaper.stub!(:new).and_return(@reaper) 143 | @cluster = Nanite::Cluster.new(@amq, 32, "the_identity", @serializer) 144 | @nanite = mock("Nanite", :identity => "nanite_id", :services => "the_nanite_services", :status => "nanite_status") 145 | end 146 | 147 | it "should add the Nanite to the nanites map" do 148 | @cluster.register(@nanite) 149 | @cluster.nanites.keys.should include('nanite_id') 150 | @cluster.nanites['nanite_id'].should_not be_nil 151 | end 152 | 153 | it "should use hash of the Nanite's services and status as value" do 154 | @cluster.register(@nanite) 155 | @cluster.nanites['nanite_id'].keys.size == 2 156 | @cluster.nanites['nanite_id'].keys.should include(:services) 157 | @cluster.nanites['nanite_id'].keys.should include(:status) 158 | @cluster.nanites['nanite_id'][:services].should == "the_nanite_services" 159 | @cluster.nanites['nanite_id'][:status].should == "nanite_status" 160 | end 161 | 162 | it "should add nanite to reaper" do 163 | @reaper.should_receive(:timeout).with('nanite_id', 33) 164 | @cluster.register(@nanite) 165 | end 166 | 167 | it "should log info message that nanite was registered" do 168 | Nanite::Log.should_receive(:info) 169 | @cluster.register(@nanite) 170 | end 171 | 172 | end # Nanite Registration 173 | 174 | 175 | describe "Route" do 176 | 177 | before(:each) do 178 | @fanout = mock("fanout") 179 | @binding = mock("binding", :subscribe => true) 180 | @queue = mock("queue", :bind => @binding) 181 | @amq = mock("AMQueue", :queue => @queue, :fanout => @fanout) 182 | @serializer = mock("Serializer") 183 | @reaper = mock("Reaper") 184 | Nanite::Reaper.stub!(:new).and_return(@reaper) 185 | @cluster = Nanite::Cluster.new(@amq, 32, "the_identity", @serializer) 186 | @request = mock("Request") 187 | end 188 | 189 | it "should publish request to all targets" do 190 | target1 = mock("Target 1") 191 | target2 = mock("Target 2") 192 | @cluster.should_receive(:publish).with(@request, target1) 193 | @cluster.should_receive(:publish).with(@request, target2) 194 | EM.run { 195 | @cluster.route(@request, [target1, target2]) 196 | EM.stop 197 | } 198 | end 199 | 200 | end # Route 201 | 202 | 203 | describe "Publish" do 204 | 205 | before(:each) do 206 | @fanout = mock("fanout") 207 | @binding = mock("binding", :subscribe => true) 208 | @queue = mock("queue", :bind => @binding, :publish => true) 209 | @amq = mock("AMQueue", :queue => @queue, :fanout => @fanout) 210 | @serializer = mock("Serializer", :dump => "dumped_value") 211 | @reaper = mock("Reaper") 212 | Nanite::Reaper.stub!(:new).and_return(@reaper) 213 | @cluster = Nanite::Cluster.new(@amq, 32, "the_identity", @serializer) 214 | @request = mock("Request", :persistent => true) 215 | @target = mock("Target of Request") 216 | end 217 | 218 | it "should serialize request before publishing it" do 219 | @serializer.should_receive(:dump).with(@request).and_return("serialized_request") 220 | @cluster.publish(@request, @target) 221 | end 222 | 223 | it "should publish request to target queue" do 224 | @queue.should_receive(:publish).with("dumped_value", anything()) 225 | @cluster.publish(@request, @target) 226 | end 227 | 228 | it "should persist request based on request setting" do 229 | @request.should_receive(:persistent).and_return(false) 230 | @queue.should_receive(:publish).with(anything(), { :persistent => false }) 231 | @cluster.publish(@request, @target) 232 | end 233 | 234 | end # Publish 235 | 236 | end # Nanite::Cluster 237 | -------------------------------------------------------------------------------- /lib/nanite/mapper.rb: -------------------------------------------------------------------------------- 1 | module Nanite 2 | # Mappers are control nodes in nanite clusters. Nanite clusters 3 | # can follow peer-to-peer model of communication as well as client-server, 4 | # and mappers are nodes that know who to send work requests to agents. 5 | # 6 | # Mappers can reside inside a front end web application written in Merb/Rails 7 | # and distribute heavy lifting to actors that register with the mapper as soon 8 | # as they go online. 9 | # 10 | # Each mapper tracks nanites registered with it. It periodically checks 11 | # when the last time a certain nanite sent a heartbeat notification, 12 | # and removes those that have timed out from the list of available workers. 13 | # As soon as a worker goes back online again it re-registers itself 14 | # and the mapper adds it to the list and makes it available to 15 | # be called again. 16 | # 17 | # This makes Nanite clusters self-healing and immune to individual node 18 | # failures. 19 | class Mapper 20 | include AMQPHelper 21 | include ConsoleHelper 22 | include DaemonizeHelper 23 | 24 | attr_reader :cluster, :identity, :job_warden, :options, :serializer, :amq 25 | 26 | DEFAULT_OPTIONS = COMMON_DEFAULT_OPTIONS.merge({:user => 'mapper', :identity => Identity.generate, :agent_timeout => 15, 27 | :offline_redelivery_frequency => 10, :persistent => false, :offline_failsafe => false}) unless defined?(DEFAULT_OPTIONS) 28 | 29 | # Initializes a new mapper and establishes 30 | # AMQP connection. This must be used inside EM.run block or if EventMachine reactor 31 | # is already started, for instance, by a Thin server that your Merb/Rails 32 | # application runs on. 33 | # 34 | # Mapper options: 35 | # 36 | # identity : identity of this mapper, may be any string 37 | # 38 | # format : format to use for packets serialization. Can be :marshal, :json or :yaml. 39 | # Defaults to Ruby's Marshall format. For interoperability with 40 | # AMQP clients implemented in other languages, use JSON. 41 | # 42 | # Note that Nanite uses JSON gem, 43 | # and ActiveSupport's JSON encoder may cause clashes 44 | # if ActiveSupport is loaded after JSON gem. 45 | # 46 | # log_level : the verbosity of logging, can be debug, info, warn, error or fatal. 47 | # 48 | # agent_timeout : how long to wait before an agent is considered to be offline 49 | # and thus removed from the list of available agents. 50 | # 51 | # log_dir : log file path, defaults to the current working directory. 52 | # 53 | # console : true tells mapper to start interactive console 54 | # 55 | # daemonize : true tells mapper to daemonize 56 | # 57 | # offline_redelivery_frequency : The frequency in seconds that messages stored in the offline queue will be retrieved 58 | # for attempted redelivery to the nanites. Default is 10 seconds. 59 | # 60 | # persistent : true instructs the AMQP broker to save messages to persistent storage so that they aren't lost when the 61 | # broker is restarted. Default is false. Can be overriden on a per-message basis using the request and push methods. 62 | # 63 | # secure : use Security features of rabbitmq to restrict nanites to themselves 64 | # 65 | # Connection options: 66 | # 67 | # vhost : AMQP broker vhost that should be used 68 | # 69 | # user : AMQP broker user 70 | # 71 | # pass : AMQP broker password 72 | # 73 | # host : host AMQP broker (or node of interest) runs on, 74 | # defaults to 0.0.0.0 75 | # 76 | # port : port AMQP broker (or node of interest) runs on, 77 | # this defaults to 5672, port used by some widely 78 | # used AMQP brokers (RabbitMQ and ZeroMQ) 79 | # 80 | # @api :public: 81 | def self.start(options = {}) 82 | new(options) 83 | end 84 | 85 | def initialize(options) 86 | @options = DEFAULT_OPTIONS.clone.merge(options) 87 | root = options[:root] || @options[:root] 88 | custom_config = if root 89 | file = File.expand_path(File.join(root, 'config.yml')) 90 | File.exists?(file) ? (YAML.load(IO.read(file)) || {}) : {} 91 | else 92 | {} 93 | end 94 | options.delete(:identity) unless options[:identity] 95 | @options.update(custom_config.merge(options)) 96 | @identity = "mapper-#{@options[:identity]}" 97 | @options[:file_root] ||= File.join(@options[:root], 'files') 98 | log_path = false 99 | if @options[:daemonize] 100 | log_path = (@options[:log_dir] || @options[:root] || Dir.pwd) 101 | end 102 | Log.init(@identity, log_path) 103 | Log.log_level = @options[:log_level] 104 | @serializer = Serializer.new(@options[:format]) 105 | daemonize if @options[:daemonize] 106 | @amq = start_amqp(@options) 107 | @cluster = Cluster.new(@amq, @options[:agent_timeout], @options[:identity], @serializer) 108 | @job_warden = JobWarden.new(@serializer) 109 | Nanite::Log.info('starting mapper') 110 | setup_queues 111 | start_console if @options[:console] && !@options[:daemonize] 112 | end 113 | 114 | # Make a nanite request which expects a response. 115 | # 116 | # ==== Parameters 117 | # type:: The dispatch route for the request 118 | # payload:: Payload to send. This will get marshalled en route 119 | # 120 | # ==== Options 121 | # :selector:: Method for selecting an actor. Default is :least_loaded. 122 | # :least_loaded:: Pick the nanite which has the lowest load. 123 | # :all:: Send the request to all nanites which respond to the service. 124 | # :random:: Randomly pick a nanite. 125 | # :rr: Select a nanite according to round robin ordering. 126 | # :target:: Select a specific nanite via identity, rather than using 127 | # a selector. 128 | # :offline_failsafe:: Store messages in an offline queue when all 129 | # the nanites are offline. Messages will be redelivered when nanites come online. 130 | # Default is false unless the mapper was started with the --offline-failsafe flag. 131 | # :persistent:: Instructs the AMQP broker to save the message to persistent 132 | # storage so that it isnt lost when the broker is restarted. 133 | # Default is false unless the mapper was started with the --persistent flag. 134 | # 135 | # ==== Block Parameters 136 | # :results:: The returned value from the nanite actor. 137 | # 138 | # @api :public: 139 | def request(type, payload = '', opts = {}, &blk) 140 | request = build_deliverable(Request, type, payload, opts) 141 | request.reply_to = identity 142 | targets = cluster.targets_for(request) 143 | if !targets.empty? 144 | job = job_warden.new_job(request, targets, blk) 145 | cluster.route(request, job.targets) 146 | job 147 | elsif opts.key?(:offline_failsafe) ? opts[:offline_failsafe] : options[:offline_failsafe] 148 | cluster.publish(request, 'mapper-offline') 149 | :offline 150 | else 151 | false 152 | end 153 | end 154 | 155 | # Make a nanite request which does not expect a response. 156 | # 157 | # ==== Parameters 158 | # type:: The dispatch route for the request 159 | # payload:: Payload to send. This will get marshalled en route 160 | # 161 | # ==== Options 162 | # :selector:: Method for selecting an actor. Default is :least_loaded. 163 | # :least_loaded:: Pick the nanite which has the lowest load. 164 | # :all:: Send the request to all nanites which respond to the service. 165 | # :random:: Randomly pick a nanite. 166 | # :rr: Select a nanite according to round robin ordering. 167 | # :offline_failsafe:: Store messages in an offline queue when all 168 | # the nanites are offline. Messages will be redelivered when nanites come online. 169 | # Default is false unless the mapper was started with the --offline-failsafe flag. 170 | # :persistent:: Instructs the AMQP broker to save the message to persistent 171 | # storage so that it isnt lost when the broker is restarted. 172 | # Default is false unless the mapper was started with the --persistent flag. 173 | # 174 | # @api :public: 175 | def push(type, payload = '', opts = {}) 176 | push = build_deliverable(Push, type, payload, opts) 177 | targets = cluster.targets_for(push) 178 | if !targets.empty? 179 | cluster.route(push, targets) 180 | true 181 | elsif opts.key?(:offline_failsafe) ? opts[:offline_failsafe] : options[:offline_failsafe] 182 | cluster.publish(push, 'mapper-offline') 183 | :offline 184 | else 185 | false 186 | end 187 | end 188 | 189 | private 190 | 191 | def build_deliverable(deliverable_type, type, payload, opts) 192 | deliverable = deliverable_type.new(type, payload, opts) 193 | deliverable.from = identity 194 | deliverable.token = Identity.generate 195 | deliverable.persistent = opts.key?(:persistent) ? opts[:persistent] : options[:persistent] 196 | deliverable 197 | end 198 | 199 | def setup_queues 200 | setup_offline_queue 201 | setup_message_queue 202 | end 203 | 204 | def setup_offline_queue 205 | offline_queue = amq.queue('mapper-offline', :durable => true) 206 | offline_queue.subscribe(:ack => true) do |info, deliverable| 207 | deliverable = serializer.load(deliverable) 208 | targets = cluster.targets_for(deliverable) 209 | unless targets.empty? 210 | info.ack 211 | if deliverable.kind_of?(Request) 212 | deliverable.reply_to = identity 213 | job_warden.new_job(deliverable, targets) 214 | end 215 | cluster.route(deliverable, targets) 216 | end 217 | end 218 | 219 | EM.add_periodic_timer(options[:offline_redelivery_frequency]) { offline_queue.recover } 220 | end 221 | 222 | def setup_message_queue 223 | amq.queue(identity, :exclusive => true).bind(amq.fanout(identity)).subscribe do |msg| 224 | job_warden.process(msg) 225 | end 226 | end 227 | end 228 | end 229 | 230 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = Nanite : A self-assembling fabric of Ruby daemons 2 | 3 | Google Group: http://groups.google.com/group/nanite 4 | irc.freenode.net: #nanite 5 | 6 | == Intro 7 | 8 | Nanite is a new way of thinking about building cloud ready web applications. Having 9 | a scalable message queueing back-end with all the discovery and dynamic load based 10 | dispatch that Nanite has is a very scalable way to construct web application back-ends. 11 | 12 | A Nanite system has two types of components. There are nanite agents, these are the 13 | daemons where you implement your code functionality as actors. And then there are 14 | mappers. 15 | 16 | Mappers are the control nodes of the system. There can be any number of mappers, these 17 | typically run inside of your merb or rails app running on the thin webserver 18 | (eventmachine is needed) but you can also run command line mappers from the shell. 19 | 20 | Each Nanite agent sends a ping to the mapper exchange every @ping_time seconds. All of 21 | the mappers are subscribed to this exchange and they all get a copy of the ping with your 22 | status update. If the mappers do not get a ping from a certain agent within a timeout 23 | @ping_time the mappers will remove that agent from any dispatch. When the agent comes 24 | back online or gets less busy it will re-advertise itself to the mapper exchange therefore 25 | adding itself back to the dispatch. This makes for a very nice self-healing cluster of 26 | worker processes and a cluster of front-end mapper processes. 27 | 28 | In your Nanites you can have any number of actor classes. These actors are like controllers 29 | in Rails or Merb and this is where you implement your own custom functionality. An actor looks 30 | like: 31 | 32 | class Foo 33 | include Nanite::Actor 34 | expose :bar 35 | def bar(payload) 36 | "got payload: #{payload}" 37 | end 38 | end 39 | Nanite::Dispatcher.register(Foo.new) 40 | 41 | The methods that you 'expose' on the actors are advertised to the mappers like: 42 | 43 | Foo#bar => /foo/bar 44 | Mock#list => /mock/list 45 | 46 | Every agent advertises its status every time it pings the mapper cluster. 47 | The default status that is advertised is the load average as a float. This 48 | is used for the default request dispatching based on least loaded server. 49 | 50 | You can change what is advertised as status to anything you want that is 51 | comparable(<=>) by doing something like this in your agent's init.rb file: 52 | 53 | status_proc = lambda { MyApp.some_statistic_indicating_load } 54 | 55 | This proc will be recalled every @ping_time and sent to the mappers. 56 | 57 | This is the 'fitness function' for selecting the least loaded Nanite. You can 58 | dream up any scheme of populating this function so the mappers can select the 59 | right Nanites based on their status. 60 | 61 | 62 | == A quick note about security: 63 | 64 | Nanite security is based upon RabbitMQ vhosts so anything attached to one vhost 65 | can talk to anything else on the same vhost. So you generally want one vhost 66 | per app space. 67 | 68 | 69 | == Installation 70 | 71 | Nanite has a lot of moving parts. Follow the directions below and we'll get you up 72 | and running in no-time. 73 | 74 | === Install Erlang (OS X) 75 | 76 | See the Erlang website for the latest info : http://www.erlang.org/download.html 77 | 78 | In your chosen source dir which we'll refer to as : 79 | 80 | cd 81 | wget http://www.erlang.org/download/otp_src_R12B-5.tar.gz 82 | tar -zxvf otp_src_R12B-5.tar.gz 83 | cd otp_src_R12B-5 84 | ./configure --enable-hipe --enable-darwin-universal 85 | make 86 | sudo make install 87 | 88 | === Install Erlang (Linux .deb) 89 | 90 | sudo apt-get install erlang-nox 91 | 92 | === Install Nanite Ruby Gem 93 | 94 | Installing the gem gives us access to the various Nanite commands in the default binary path. 95 | 96 | cd 97 | git clone git://github.com/ezmobius/nanite.git 98 | cd nanite 99 | rake gem 100 | sudo gem install pkg/nanite-.gem 101 | 102 | 103 | === Install EventMachine Ruby Gem 104 | 105 | sudo gem install eventmachine 106 | 107 | === Install AMQP Ruby Gem 108 | 109 | # install version >= 0.6.0 from RubyForge 110 | sudo gem install amqp 111 | 112 | # or install from source 113 | cd 114 | git clone git://github.com/tmm1/amqp.git 115 | cd amqp && rake gem && sudo gem install amqp-.gem 116 | 117 | === Install RabbitMQ from source tarball (OS X and generic Linux) 118 | 119 | In short, you'll need a working erlang installation, python, and simplejson. If your python ("python -V") is 2.6, simplejson is included 120 | in your distribution. Otherwise, install simplejson: 121 | 122 | easy_install simplejson 123 | 124 | If you don't have easy_install, install setuptools from http://pypi.python.org/pypi/setuptools: 125 | 126 | # Get the latest .egg file for your version of python ("python -V"), then run it: 127 | wget http://pypi.python.org/packages/2.5/s/setuptools/setuptools-0.6c9-py2.5.egg 128 | sh setuptools-0.6c9-py2.5.egg 129 | 130 | and install simplejson, which is needed for the "make" step below. 131 | 132 | These instructions assume the latest RabbitMQ release 1.5.3: 133 | 134 | # Download somewhere 135 | cd /root 136 | wget http://www.rabbitmq.com/releases/rabbitmq-server/v1.5.3/rabbitmq-server-1.5.3.tar.gz 137 | 138 | # Go to your erlang lib directory, usually /usr/lib/erlang/lib or: 139 | cd /usr/local/lib/erlang/lib 140 | 141 | tar -zxf ~/rabbitmq-server-1.5.3.tar.gz 142 | cd rabbitmq-server-1.5.3 143 | make 144 | 145 | # There is no "make install" phase. 146 | 147 | Be sure to add the /usr/local/lib/erlang/lib/rabbitmq-server-1.5.3/scripts to your $PATH. 148 | 149 | The following websites may also be useful: 150 | * RabbitMQ website for the latest info : http://www.rabbitmq.com/download.html 151 | * RabbitMQ server source download : http://www.rabbitmq.com/server.html 152 | * RabbitMQ build instructions : http://www.rabbitmq.com/build-server.html 153 | * RabbitMQ install instructions : http://www.rabbitmq.com/install.html 154 | 155 | 156 | === Install RabbitMQ (Linux .deb) 157 | 158 | wget http://www.rabbitmq.com/releases/rabbitmq-server/v1.5.3/rabbitmq-server_1.5.3-1_all.deb 159 | sudo apt-get install logrotate 160 | sudo dpkg -i rabbitmq-server_1.5.3-1_all.deb 161 | 162 | == Test your installation 163 | 164 | === Test your Erlang install 165 | 166 | Start an Erlang shell 167 | 168 | erl 169 | 170 | Enter the following commands in the Erlang shell (When installed correctly each should print a great deal of erlang config info): 171 | 172 | rabbit:module_info(). 173 | 174 | Exit the Erlang shell with the following command (or Ctrl-c): 175 | 176 | q(). 177 | 178 | 179 | === Start RabbitMQ 180 | 181 | All directories will be automatically set up on first run. 182 | 183 | To run RabbitMQ in the foreground: 184 | sudo rabbitmq-server 185 | 186 | To run RabbitMQ in the background: 187 | sudo rabbitmq-server -detached 188 | 189 | To check status: 190 | rabbitmqctl status 191 | 192 | To stop server: 193 | rabbitmqctl stop 194 | 195 | You can learn more about RabbitMQ admin here: http://www.rabbitmq.com/admin-guide.html 196 | 197 | 198 | === Test Ruby/EventMachine/AMQP end-to-end with a running RabbitMQ instance 199 | 200 | You can test that all the moving parts are working end-to-end by running one of the AMQP example programs. There are a number of example tests provided with AMQP but the one we will try simply queues and prints two messages immediately, and another one five seconds later. 201 | 202 | # this test can only be run if you made a local clone 203 | # of the amqp repository during the amqp installation above 204 | # (it does not matter however if the gem was installed from src or rubyforge) 205 | cd /amqp 206 | ruby examples/mq/simple-get.rb 207 | 208 | 209 | === Test Nanite (finally) 210 | 211 | First we'll do a little setup and run a script which adds an agent account (nanite), a mapper account (mapper) 212 | and a '/nanite' vhost to RabbitMQ. RabbitMQ broker must of course be running before you run this script. 213 | 214 | cd nanite 215 | ./examples/rabbitconf.rb 216 | 217 | Now lets run a few agents. Each of these is a long running process and needs to run in its own shell. Each agent also needs its own unique identity, or token to differentiate it from the other running agents. You can try opening a couple of them now if you like. The agents will generally provide no output when running. You can always get more help about the nanite command with 'nanite --help'. 218 | 219 | First shell 220 | 221 | cd examples/simpleagent 222 | nanite-agent --token fred 223 | 224 | Second shell 225 | 226 | cd examples/simpleagent 227 | nanite-agent --token bob 228 | 229 | Now run a mapper. Mappers can be run from within your Merb or Rails app, from an interactive irb shell, or from the command line. For this example we'll run it from the command line so open a third shell window and run the following: 230 | 231 | cd examples 232 | ./cli.rb 233 | 234 | Which should soon return something like the following. 235 | 236 | {"bob"=>"hello nanite"} # where the '--token bob' parameter was passed 237 | {"55a7f300c454203eacc218b6fbd2edc6"=>"hello nanite"} # where no '--token' was passed. auto-generated identity. 238 | 239 | Now if you want to make this interesting, you can issue a Ctrl-c in one of the agent's windows to kill it. And then run cli.rb again. You should see that you still get a result back since the mapper is finding the remaining agent to do its work. 240 | 241 | If you want to try requesting work to be done by an agent from an interactive command shell try the following. This assumes that you have the agents running as indicated in the example above ('>>' is the nanite shell prompt). 242 | 243 | cd nanite 244 | ./bin/nanite-mapper -i -u mapper -p testing -v /nanite 245 | Starting mapper console 246 | >> request('/simple/echo') {|res| p res } 247 | 248 | By default this will dispatch to the agent with the lowest reported load average. 249 | 250 | There are a few other selectors as well: 251 | 252 | # run this request on *all* agents that expose the /foo/bar Foo#bar actor 253 | >> request('/foo/bar', 'hi', :selector => :all) {|res| p res } 254 | 255 | # run this request on one random agent that expose the /whatever/hello Whatever#hello actor 256 | >> request('/whatever/hello', 42, :selector => :random) {|res| p res } 257 | 258 | You can create your own selectors based on arbitrary stuff you put in status from your agents see cluster.rb for examples of how least_loaded, all and random are implemented. 259 | 260 | You can run as many mappers as you want, they will all be hot masters. 261 | 262 | The calls are asynchronous. This means the block you pass to Nanite::Agent#request is not run until the response from the agent(s) have returned. So keep that in mind. Should you need to poll from an ajax web app for results you should have your block stuff the results in the database for any web front end to pick up with the next poll. 263 | 264 | Another option to test your agents is to use nanite-admin 265 | 266 | $ nanite-admin 267 | starting nanite-admin 268 | >> Thin web server (v1.0.1 codename ?) 269 | >> Maximum connections set to 1024 270 | >> Listening on 0.0.0.0:4000, CTRL+C to stop 271 | 272 | and bring up the Nanite Control Tower at http://localhost:4000 . 273 | 274 | Have fun! 275 | 276 | == Web Framework Integration 277 | 278 | The integration of Nanite into web frameworks depends on the web server running our application and, more importantly, if it uses EventMachine or not. 279 | 280 | Thin is EventMachine-based, so we only need to make sure that the EventMachine reactor is already 'heated' up 281 | 282 | Thread.new do 283 | until EM.reactor_running? 284 | sleep 1 285 | end 286 | Nanite.start_mapper(:host => 'localhost', :user => 'mapper', :pass => 'testing', :vhost => '/nanite', :log_level => 'info') 287 | end 288 | 289 | Mongrel on the other hand does not use EventMachine and therefore requires to wrap the start of our mapper 290 | 291 | Thread.new do 292 | EM.run do 293 | Nanite.start_mapper(:host => 'localhost', :user => 'mapper', :pass => 'testing', :vhost => '/nanite', :log_level => 'info') 294 | end 295 | end 296 | 297 | Where to put the mapper initialization code depends on the framework and our preference. 298 | For Rails the canonical place to start our mapper is within nanite.rb (or any other filename you prefer) in config/initalizers. 299 | In Merb we can use init.rb in config. 300 | 301 | 302 | == Troubleshooting 303 | 304 | ** IMPORTANT ** 305 | If you are using Apple's built in Ruby that comes with Leopard (10.5.x) or Tiger (10.4.x) then your 306 | READLINE lib is hosed and the interactive shell will not work. As soon as you drop into a shell 307 | Apple's fakey READLINE will halt the event machine loop so you won't see any nanites 308 | registering. I don't have a good workaround except to tell you not to use Apple's Ruby, 309 | build your own or use ports. 310 | 311 | 312 | ** What to do if rabbitconf.rb dies with: {badrpc,nodedown} and nothing you do seems to matter ** 313 | 314 | If rabbitconf.rb dies saying "{badrpc,nodedown}" it means that for some reason, 315 | the rabbitmqctl program rabbitconf.rb is using to setup the agent accounts for nanite is 316 | not able to connect to your RabbitMQ server. Assuming RabbitMQ is running and is 317 | known to work (try the examples that come with the amqp library), then there's a chance 318 | something is going wrong with using short node names in your Erlang install. 319 | 320 | The easiest way to verify it is by starting two separate Erlang shells like this (note that "odin" is my hostname): 321 | 322 | $ erl -sname fred 323 | (fred@odin)1> 324 | 325 | $ erl -sname bob 326 | (bob@odin)1> 327 | 328 | And then trying to 'ping' one from the other. In the 'fred' node you can do that like this: 329 | 330 | (fred@odin)1> net_adm:ping(bob@odin). 331 | pang 332 | 333 | Note : If your hostname has a hyphen in it (e.g. macbook-pro) you should include the user@hostname in quotes like: 334 | 335 | net_adm:ping('bob@macbook-pro'). 336 | 337 | If you see 'pang' (which is apparently Swedish for something like "crap! it's broken.") then short name distribution isn't working for you, and you need to fall back to using a full name. If you see 'pong', then that's not actually your problem. 338 | 339 | First, verify that your system's hostname is set to a fully qualified domain name. On OS X it can end in '.local': 340 | 341 | hostname 342 | odin.local 343 | 344 | Then test that *this* will work by starting a node with the full name of 'fred@': 345 | 346 | erl -name fred@odin.local 347 | (fred@odin.local)1> 348 | 349 | then bob@, and then finally try to ping fred: 350 | 351 | erl -name bob@odin.local 352 | (bob@odin.local)1> net_adm:ping(fred@odin.local). 353 | pong 354 | 355 | In my case, it looks like that worked. Now... on to getting rabbitconf.rb to run! To do that, you need to edit the 'rabbitmq-server' and 'rabbitmqctl' scripts in your RabbitMQ distribution and edit the -sname arguments to use -name and a full name. 356 | 357 | --------------------------------------------------------------------------------