toro 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/base'
3
+
4
+ module Toro
5
+ module Generators
6
+ class InstallGenerator < ::Rails::Generators::Base
7
+ include ::Rails::Generators::Migration
8
+ source_root File.expand_path('../templates', __FILE__)
9
+ desc "Install the migrations"
10
+
11
+ def self.next_migration_number(path)
12
+ unless @prev_migration_nr
13
+ @prev_migration_nr = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
14
+ else
15
+ @prev_migration_nr += 1
16
+ end
17
+ @prev_migration_nr.to_s
18
+ end
19
+
20
+ def install_migrations
21
+ migration_template "create_toro_jobs.rb", "db/migrate/create_toro_jobs.rb"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,9 @@
1
+ class CreateToroJobs < ActiveRecord::Migration
2
+ def self.up
3
+ Toro::Database.up
4
+ end
5
+
6
+ def self.down
7
+ Toro::Database.down
8
+ end
9
+ end
@@ -0,0 +1,19 @@
1
+ task :environment
2
+
3
+ namespace :toro do
4
+ desc "Start a new worker for the (default or $QUEUE) queue"
5
+ task :start => :environment do
6
+ cli = Toro::CLI.new
7
+ cli.run
8
+ end
9
+
10
+ desc "Setup Toro tables and functions in database"
11
+ task :up => :environment do
12
+ Toro::Database.up
13
+ end
14
+
15
+ desc "Remove Toro tables and functions from database."
16
+ task :down => :environment do
17
+ Toro::Database.down
18
+ end
19
+ end
data/lib/toro.rb CHANGED
@@ -1,4 +1,54 @@
1
+ # Dependencies
2
+ require 'active_support/all'
3
+ require 'active_record'
4
+ require 'celluloid'
5
+ require 'nested-hstore'
6
+ require 'socket'
7
+
8
+ # Self
9
+ directory = File.dirname(File.absolute_path(__FILE__))
1
10
  require "#{directory}/toro/version.rb"
11
+ Dir.glob("#{directory}/toro/*.rb") { |file| require file }
12
+ Dir.glob("#{directory}/toro/middleware/**/*.rb") { |file| require file }
13
+ Dir.glob("#{directory}/generators/**/*.rb") { |file| require file }
2
14
 
3
15
  module Toro
4
- end
16
+ DEFAULTS = {
17
+ default_queue: 'default',
18
+ graceful_shutdown_time: 1,
19
+ hard_shutdown_time: 8,
20
+ listen_interval: 5
21
+ }
22
+
23
+ class << self
24
+ def options
25
+ @options ||= DEFAULTS.dup
26
+ end
27
+
28
+ def options=(options)
29
+ @options = options
30
+ end
31
+
32
+ def configure_server
33
+ yield self
34
+ end
35
+
36
+ def server_middleware
37
+ @server_chain ||= Processor.default_middleware
38
+ yield @server_chain if block_given?
39
+ @server_chain
40
+ end
41
+
42
+ def process_identity
43
+ @process_identity ||= "#{Socket.gethostname}:#{Process.pid}"
44
+ end
45
+
46
+ def logger
47
+ Toro::Logging.logger
48
+ end
49
+
50
+ def logger=(log)
51
+ Toro::Logging.logger = log
52
+ end
53
+ end
54
+ end
data/lib/toro/actor.rb ADDED
@@ -0,0 +1,8 @@
1
+ module Toro
2
+ module Actor
3
+ def self.included(klass)
4
+ klass.__send__(:include, Celluloid)
5
+ klass.__send__(:task_class, Celluloid::TaskThread)
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ module Toro
2
+ module ActorManager
3
+ def register_actor(key, proxy)
4
+ actors[key] = proxy
5
+ end
6
+
7
+ def actors
8
+ @actors ||= {}
9
+ end
10
+ end
11
+ end
data/lib/toro/cli.rb ADDED
@@ -0,0 +1,87 @@
1
+ module Toro
2
+ class CLI
3
+ attr_reader :options
4
+
5
+ def initialize(arguments=ARGV)
6
+ @options = arguments_to_options(arguments)
7
+ @manager = Manager.new(@options)
8
+ end
9
+
10
+ def run
11
+ Toro.logger.info 'Starting processing (press Control-C to stop)'
12
+
13
+ read_io, write_io = IO.pipe
14
+
15
+ %w(INT TERM USR1 USR2 TTIN).each do |signal|
16
+ begin
17
+ trap signal do
18
+ write_io.puts(signal)
19
+ end
20
+ rescue ArgumentError
21
+ Toro.logger.debug "Signal #{signal} not supported"
22
+ end
23
+ end
24
+
25
+ begin
26
+ @manager.start
27
+
28
+ while readable_io = IO.select([read_io])
29
+ signal = readable_io.first[0].gets.strip
30
+ handle_signal(signal)
31
+ end
32
+ rescue Interrupt
33
+ Toro.logger.info 'Shutting down...'
34
+ @manager.stop
35
+ end
36
+
37
+ # Explicitly exit so busy Processor threads can't block
38
+ # process shutdown.
39
+ exit 0
40
+ end
41
+
42
+ protected
43
+
44
+ def arguments_to_options(arguments)
45
+ options = {}
46
+ OptionParser.new do |opts|
47
+ opts.on('-q', '--queue NAME', 'Queue') { |v| options[:queues] ||= []; options[:queues] << parse_queue(v) }
48
+ opts.on('-c', '--concurrency CONCURRENCY', 'Concurrency') { |v| options[:concurrency] = Integer(v) }
49
+ end.parse!(arguments)
50
+ options
51
+ end
52
+
53
+ def parse_queue(value)
54
+ value.strip
55
+ end
56
+
57
+ def handle_signal(signal)
58
+ Toro.logger.debug "Got #{signal} signal"
59
+ case signal
60
+ when 'INT'
61
+ # Handle Ctrl-C in JRuby like MRI
62
+ # http://jira.codehaus.org/browse/JRUBY-4637
63
+ raise Interrupt
64
+ when 'TERM'
65
+ # Heroku sends TERM and then waits 10 seconds for process to exit.
66
+ raise Interrupt
67
+ when 'USR1'
68
+ Toro.logger.info "Received USR1, no longer accepting new work"
69
+ @manager.async.stop
70
+ when 'USR2'
71
+ if Toro.options[:logfile]
72
+ Toro.logger.info "Received USR2, reopening log file"
73
+ Toro::Logging.reopen_logs
74
+ end
75
+ when 'TTIN'
76
+ Thread.list.each do |thread|
77
+ Toro.logger.info "Thread T#{thread.object_id.to_s(36)} #{thread['label']}"
78
+ if thread.backtrace
79
+ Toro.logger.info thread.backtrace.join("\n")
80
+ else
81
+ Toro.logger.info "<no backtrace available>"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,17 @@
1
+ module Toro
2
+ class Client
3
+ class << self
4
+ def create_job(item)
5
+ item.stringify_keys!
6
+ job_attributes = item_to_job_attributes(item)
7
+ Job.create!(job_attributes)
8
+ end
9
+
10
+ private
11
+
12
+ def item_to_job_attributes(item)
13
+ { 'status' => 'queued' }.merge(item)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,38 @@
1
+ module Toro
2
+ class Database
3
+ SQL_DIRECTORY = Pathname.new(File.expand_path('sql', File.dirname(__FILE__)))
4
+
5
+ class << self
6
+ def up
7
+ execute_file('up')
8
+ end
9
+
10
+ def down
11
+ execute_file('down')
12
+ end
13
+
14
+ def connection
15
+ ActiveRecord::Base.connection
16
+ end
17
+
18
+ def raw_connection
19
+ connection.raw_connection
20
+ end
21
+
22
+ def query(sql, parameters=[])
23
+ raw_connection.exec(sql, parameters)
24
+ end
25
+
26
+ def with_connection(&block)
27
+ ActiveRecord::Base.connection_pool.with_connection(&block)
28
+ end
29
+
30
+ private
31
+
32
+ def execute_file(file_name)
33
+ file_path = SQL_DIRECTORY.join("#{file_name}.sql")
34
+ connection.execute(File.read(file_path))
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,40 @@
1
+ module Toro
2
+ class Fetcher
3
+ include Actor
4
+
5
+ def initialize(options={})
6
+ defaults = {
7
+ queues: [Toro.options[:default_queue]]
8
+ }
9
+ options.reverse_merge!(defaults)
10
+ @queues = options[:queues]
11
+ @manager = options[:manager]
12
+ raise 'No manager provided' if @manager.blank?
13
+ end
14
+
15
+ def notify
16
+ if @manager.is_ready?
17
+ job = retrieve
18
+ @manager.assign(job) if job
19
+ end
20
+ end
21
+
22
+ def fetch
23
+ job = retrieve
24
+ @manager.async.assign(job) if job
25
+ end
26
+
27
+ def retrieve
28
+ job = nil
29
+ queue_list = @queues.map { |queue| "'#{queue}'" }.join(', ')
30
+ sql = "SELECT * FROM toro_pop(ARRAY[#{queue_list}]::TEXT[], '#{Toro.process_identity}')"
31
+ result = nil
32
+ Toro::Database.with_connection do
33
+ result = Toro::Database.query(sql).first
34
+ result = nil if result['id'].nil?
35
+ end
36
+ return nil if result.nil?
37
+ Job.instantiate(result)
38
+ end
39
+ end
40
+ end
data/lib/toro/job.rb ADDED
@@ -0,0 +1,38 @@
1
+ module Toro
2
+ class Job < ActiveRecord::Base
3
+ if ActiveRecord::VERSION::MAJOR < 4 || ActiveRecord.constants.include?(:MassAssignmentSecurity)
4
+ attr_accessible :queue, :class_name, :args, :name, :created_at, :scheduled_at, :started_at, :finished_at,
5
+ :status, :started_by, :properties
6
+ end
7
+
8
+ serialize :args
9
+ serialize :properties, ActiveRecord::Coders::NestedHstore
10
+
11
+ self.table_name_prefix = 'toro_'
12
+
13
+ STATUSES = [
14
+ 'queued',
15
+ 'running',
16
+ 'complete',
17
+ 'failed',
18
+ 'scheduled'
19
+ ]
20
+
21
+ class << self
22
+ def statuses
23
+ STATUSES
24
+ end
25
+ end
26
+
27
+ def set_properties(hash)
28
+ self.properties ||= {}
29
+ hash.each do |key, value|
30
+ self.properties[key.to_s] = value
31
+ end
32
+ end
33
+
34
+ def to_s
35
+ "Toro::Job ##{id}"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,44 @@
1
+ module Toro
2
+ class Listener
3
+ include Actor
4
+
5
+ def initialize(options={})
6
+ defaults = {
7
+ queues: [Toro.options[:default_queue]]
8
+ }
9
+ options.reverse_merge!(defaults)
10
+ @queues = options[:queues]
11
+ @fetcher = options[:fetcher]
12
+ @manager = options[:manager]
13
+ @is_done = false
14
+ raise 'No fetcher provided' if @fetcher.blank?
15
+ raise 'No manager provided' if @manager.blank?
16
+ end
17
+
18
+ def start
19
+ @manager.register_actor(:listener, self)
20
+ Toro::Database.with_connection do
21
+ Toro::Database.raw_connection.async_exec(channels.map { |channel| "LISTEN #{channel}" }.join('; '))
22
+ wait_for_notify
23
+ end
24
+ end
25
+
26
+ def stop
27
+ Toro::Database.raw_connection.async_exec(channels.map { |channel| "UNLISTEN #{channel}" }.join('; '))
28
+ @is_done = true
29
+ end
30
+
31
+ protected
32
+
33
+ def wait_for_notify
34
+ Toro::Database.raw_connection.wait_for_notify(Toro.options[:listen_interval]) do |channel, pid, payload|
35
+ @fetcher.notify
36
+ end
37
+ wait_for_notify unless @is_done
38
+ end
39
+
40
+ def channels
41
+ @queues.map { |queue| "toro_#{queue}" }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,80 @@
1
+ module Toro
2
+ module Logging
3
+ class Formatter < Logger::Formatter
4
+ def call(severity, time, program_name, message)
5
+ "[#{time.utc.iso8601} P-#{Process.pid} T-#{Thread.current.object_id.to_s(36)}] #{severity} -- Toro: #{message}\n"
6
+ end
7
+ end
8
+
9
+ class << self
10
+ def initialize_logger(log_target=nil)
11
+ if log_target.nil?
12
+ log_target = $TESTING ? '/dev/null' : STDOUT
13
+ end
14
+
15
+ old_logger = defined?(@logger) ? @logger : nil
16
+ @logger = Logger.new(log_target)
17
+ @logger.level = $TESTING ? Logger::DEBUG : Logger::INFO
18
+ @logger.formatter = Formatter.new
19
+ old_logger.close if old_logger && !$TESTING # don't want to close testing's STDOUT logging
20
+ Celluloid.logger = @logger
21
+ @logger
22
+ end
23
+
24
+ def logger
25
+ defined?(@logger) ? @logger : initialize_logger
26
+ end
27
+
28
+ def logger=(log)
29
+ @logger = (log ? log : Logger.new('/dev/null'))
30
+ Celluloid.logger = @logger
31
+ @logger
32
+ end
33
+
34
+ # This reopens ALL logfiles in the process that have been rotated
35
+ # using logrotate(8) (without copytruncate) or similar tools.
36
+ # A +File+ object is considered for reopening if it is:
37
+ # 1) opened with the O_APPEND and O_WRONLY flags
38
+ # 2) the current open file handle does not match its original open path
39
+ # 3) unbuffered (as far as userspace buffering goes, not O_SYNC)
40
+ # Returns the number of files reopened
41
+ def reopen_logs
42
+ to_reopen = []
43
+ append_flags = File::WRONLY | File::APPEND
44
+
45
+ ObjectSpace.each_object(File) do |fp|
46
+ begin
47
+ if !fp.closed? && fp.stat.file? && fp.sync && (fp.fcntl(Fcntl::F_GETFL) & append_flags) == append_flags
48
+ to_reopen << fp
49
+ end
50
+ rescue IOError, Errno::EBADF
51
+ end
52
+ end
53
+
54
+ nr = 0
55
+ to_reopen.each do |fp|
56
+ orig_st = begin
57
+ fp.stat
58
+ rescue IOError, Errno::EBADF
59
+ next
60
+ end
61
+
62
+ begin
63
+ b = File.stat(fp.path)
64
+ next if orig_st.ino == b.ino && orig_st.dev == b.dev
65
+ rescue Errno::ENOENT
66
+ end
67
+
68
+ begin
69
+ File.open(fp.path, 'a') { |tmpfp| fp.reopen(tmpfp) }
70
+ fp.sync = true
71
+ nr += 1
72
+ rescue IOError, Errno::EBADF
73
+ # not much we can do...
74
+ end
75
+ end
76
+ nr
77
+ end
78
+ end
79
+ end
80
+ end