toro 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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