qs 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. data/.gitignore +1 -0
  2. data/Gemfile +6 -1
  3. data/LICENSE.txt +1 -1
  4. data/bench/config.qs +46 -0
  5. data/bench/queue.rb +8 -0
  6. data/bench/report.rb +114 -0
  7. data/bench/report.txt +11 -0
  8. data/bin/qs +7 -0
  9. data/lib/qs/cli.rb +124 -0
  10. data/lib/qs/client.rb +121 -0
  11. data/lib/qs/config_file.rb +79 -0
  12. data/lib/qs/daemon.rb +350 -0
  13. data/lib/qs/daemon_data.rb +46 -0
  14. data/lib/qs/error_handler.rb +58 -0
  15. data/lib/qs/job.rb +70 -0
  16. data/lib/qs/job_handler.rb +90 -0
  17. data/lib/qs/logger.rb +23 -0
  18. data/lib/qs/payload_handler.rb +136 -0
  19. data/lib/qs/pid_file.rb +42 -0
  20. data/lib/qs/process.rb +136 -0
  21. data/lib/qs/process_signal.rb +20 -0
  22. data/lib/qs/qs_runner.rb +49 -0
  23. data/lib/qs/queue.rb +69 -0
  24. data/lib/qs/redis_item.rb +33 -0
  25. data/lib/qs/route.rb +52 -0
  26. data/lib/qs/runner.rb +26 -0
  27. data/lib/qs/test_helpers.rb +17 -0
  28. data/lib/qs/test_runner.rb +43 -0
  29. data/lib/qs/version.rb +1 -1
  30. data/lib/qs.rb +92 -2
  31. data/qs.gemspec +7 -2
  32. data/test/helper.rb +8 -1
  33. data/test/support/app_daemon.rb +74 -0
  34. data/test/support/config.qs +7 -0
  35. data/test/support/config_files/empty.qs +0 -0
  36. data/test/support/config_files/invalid.qs +1 -0
  37. data/test/support/config_files/valid.qs +7 -0
  38. data/test/support/config_invalid_run.qs +3 -0
  39. data/test/support/config_no_run.qs +0 -0
  40. data/test/support/factory.rb +14 -0
  41. data/test/support/pid_file_spy.rb +19 -0
  42. data/test/support/runner_spy.rb +17 -0
  43. data/test/system/daemon_tests.rb +226 -0
  44. data/test/unit/cli_tests.rb +188 -0
  45. data/test/unit/client_tests.rb +269 -0
  46. data/test/unit/config_file_tests.rb +59 -0
  47. data/test/unit/daemon_data_tests.rb +96 -0
  48. data/test/unit/daemon_tests.rb +702 -0
  49. data/test/unit/error_handler_tests.rb +163 -0
  50. data/test/unit/job_handler_tests.rb +253 -0
  51. data/test/unit/job_tests.rb +132 -0
  52. data/test/unit/logger_tests.rb +38 -0
  53. data/test/unit/payload_handler_tests.rb +276 -0
  54. data/test/unit/pid_file_tests.rb +70 -0
  55. data/test/unit/process_signal_tests.rb +61 -0
  56. data/test/unit/process_tests.rb +371 -0
  57. data/test/unit/qs_runner_tests.rb +166 -0
  58. data/test/unit/qs_tests.rb +217 -0
  59. data/test/unit/queue_tests.rb +132 -0
  60. data/test/unit/redis_item_tests.rb +49 -0
  61. data/test/unit/route_tests.rb +81 -0
  62. data/test/unit/runner_tests.rb +63 -0
  63. data/test/unit/test_helper_tests.rb +61 -0
  64. data/test/unit/test_runner_tests.rb +128 -0
  65. metadata +180 -15
@@ -0,0 +1,136 @@
1
+ require 'benchmark'
2
+ require 'dat-worker-pool'
3
+ require 'qs'
4
+ require 'qs/error_handler'
5
+ require 'qs/job'
6
+ require 'qs/logger'
7
+
8
+ module Qs
9
+
10
+ class PayloadHandler
11
+
12
+ attr_reader :daemon_data, :redis_item, :logger
13
+
14
+ def initialize(daemon_data, redis_item)
15
+ @daemon_data = daemon_data
16
+ @redis_item = redis_item
17
+ @logger = Qs::Logger.new(
18
+ @daemon_data.logger,
19
+ @daemon_data.verbose_logging
20
+ )
21
+ end
22
+
23
+ def run
24
+ log_received
25
+ benchmark = Benchmark.measure{ run!(@daemon_data, @redis_item) }
26
+ @redis_item.time_taken = RoundedTime.new(benchmark.real)
27
+ log_complete(@redis_item)
28
+ raise_if_debugging!(@redis_item.exception)
29
+ end
30
+
31
+ private
32
+
33
+ def run!(daemon_data, redis_item)
34
+ redis_item.started = true
35
+
36
+ payload = Qs.deserialize(redis_item.serialized_payload)
37
+ job = Qs::Job.parse(payload)
38
+ log_job(job)
39
+ redis_item.job = job
40
+
41
+ route = daemon_data.route_for(job.name)
42
+ log_handler_class(route.handler_class)
43
+ redis_item.handler_class = route.handler_class
44
+
45
+ route.run(job, daemon_data)
46
+ redis_item.finished = true
47
+ rescue DatWorkerPool::ShutdownError => exception
48
+ if redis_item.started
49
+ error = ShutdownError.new(exception.message)
50
+ error.set_backtrace(exception.backtrace)
51
+ handle_exception(error, daemon_data, redis_item)
52
+ end
53
+ raise exception
54
+ rescue StandardError => exception
55
+ handle_exception(exception, daemon_data, redis_item)
56
+ end
57
+
58
+ def handle_exception(exception, daemon_data, redis_item)
59
+ error_handler = Qs::ErrorHandler.new(exception, {
60
+ :daemon_data => daemon_data,
61
+ :queue_redis_key => redis_item.queue_redis_key,
62
+ :serialized_payload => redis_item.serialized_payload,
63
+ :job => redis_item.job,
64
+ :handler_class => redis_item.handler_class
65
+ }).tap(&:run)
66
+ redis_item.exception = error_handler.exception
67
+ log_exception(redis_item.exception)
68
+ end
69
+
70
+ def raise_if_debugging!(exception)
71
+ raise exception if exception && ENV['QS_DEBUG']
72
+ end
73
+
74
+ def log_received
75
+ log_verbose "===== Running job ====="
76
+ end
77
+
78
+ def log_job(job)
79
+ log_verbose " Job: #{job.name.inspect}"
80
+ log_verbose " Params: #{job.params.inspect}"
81
+ end
82
+
83
+ def log_handler_class(handler_class)
84
+ log_verbose " Handler: #{handler_class}"
85
+ end
86
+
87
+ def log_complete(redis_item)
88
+ log_verbose "===== Completed in #{redis_item.time_taken}ms ====="
89
+ summary_line_args = {
90
+ 'time' => redis_item.time_taken,
91
+ 'handler' => redis_item.handler_class
92
+ }
93
+ if (job = redis_item.job)
94
+ summary_line_args['job'] = job.name
95
+ summary_line_args['params'] = job.params
96
+ end
97
+ if (exception = redis_item.exception)
98
+ summary_line_args['error'] = "#{exception.inspect}"
99
+ end
100
+ log_summary SummaryLine.new(summary_line_args)
101
+ end
102
+
103
+ def log_exception(exception)
104
+ backtrace = exception.backtrace.join("\n")
105
+ message = "#{exception.class}: #{exception.message}\n#{backtrace}"
106
+ log_verbose(message, :error)
107
+ end
108
+
109
+ def log_verbose(message, level = :info)
110
+ self.logger.verbose.send(level, "[Qs] #{message}")
111
+ end
112
+
113
+ def log_summary(message, level = :info)
114
+ self.logger.summary.send(level, "[Qs] #{message}")
115
+ end
116
+
117
+ module RoundedTime
118
+ ROUND_PRECISION = 2
119
+ ROUND_MODIFIER = 10 ** ROUND_PRECISION
120
+ def self.new(time_in_seconds)
121
+ (time_in_seconds * 1000 * ROUND_MODIFIER).to_i / ROUND_MODIFIER.to_f
122
+ end
123
+ end
124
+
125
+ module SummaryLine
126
+ def self.new(line_attrs)
127
+ attr_keys = %w{time handler job params error}
128
+ attr_keys.map{ |k| "#{k}=#{line_attrs[k].inspect}" }.join(' ')
129
+ end
130
+ end
131
+
132
+ end
133
+
134
+ ShutdownError = Class.new(DatWorkerPool::ShutdownError)
135
+
136
+ end
@@ -0,0 +1,42 @@
1
+ require 'fileutils'
2
+
3
+ module Qs
4
+
5
+ class PIDFile
6
+ attr_reader :path
7
+
8
+ def initialize(path)
9
+ @path = (path || '/dev/null').to_s
10
+ end
11
+
12
+ def pid
13
+ pid = File.read(@path).strip
14
+ pid && !pid.empty? ? pid.to_i : raise('no pid in file')
15
+ rescue StandardError => exception
16
+ error = InvalidError.new("A PID couldn't be read from #{@path.inspect}")
17
+ error.set_backtrace(exception.backtrace)
18
+ raise error
19
+ end
20
+
21
+ def write
22
+ FileUtils.mkdir_p(File.dirname(@path))
23
+ File.open(@path, 'w'){ |f| f.puts ::Process.pid }
24
+ rescue StandardError => exception
25
+ error = InvalidError.new("Can't write pid to file #{@path.inspect}")
26
+ error.set_backtrace(exception.backtrace)
27
+ raise error
28
+ end
29
+
30
+ def remove
31
+ FileUtils.rm_f(@path)
32
+ end
33
+
34
+ def to_s
35
+ @path
36
+ end
37
+
38
+ InvalidError = Class.new(RuntimeError)
39
+
40
+ end
41
+
42
+ end
data/lib/qs/process.rb ADDED
@@ -0,0 +1,136 @@
1
+ require 'qs/pid_file'
2
+
3
+ module Qs
4
+
5
+ class Process
6
+
7
+ attr_reader :daemon, :name, :pid_file, :restart_cmd
8
+
9
+ def initialize(daemon, options = nil)
10
+ options ||= {}
11
+ @daemon = daemon
12
+ @logger = @daemon.logger
13
+ @pid_file = PIDFile.new(@daemon.pid_file)
14
+ @restart_cmd = RestartCmd.new
15
+
16
+ @name = ignore_if_blank(ENV['QS_PROCESS_NAME']) || "qs-#{@daemon.name}"
17
+
18
+ @daemonize = !!options[:daemonize]
19
+ @skip_daemonize = !!ignore_if_blank(ENV['QS_SKIP_DAEMONIZE'])
20
+ @restart = false
21
+ end
22
+
23
+ def run
24
+ ::Process.daemon(true) if self.daemonize?
25
+ log "Starting Qs daemon for #{@daemon.name}..."
26
+
27
+ $0 = @name
28
+ @pid_file.write
29
+ log "PID: #{@pid_file.pid}"
30
+
31
+ ::Signal.trap("TERM"){ @daemon.stop }
32
+ ::Signal.trap("INT"){ @daemon.halt }
33
+ ::Signal.trap("USR2") do
34
+ @daemon.stop
35
+ @restart = true
36
+ end
37
+
38
+ thread = @daemon.start
39
+ log "#{@daemon.name} daemon started and ready."
40
+ thread.join
41
+ run_restart_cmd if self.restart?
42
+ rescue StandardError => exception
43
+ log "Error: #{exception.message}"
44
+ log "#{@daemon.name} daemon never started."
45
+ ensure
46
+ @pid_file.remove
47
+ end
48
+
49
+ def daemonize?
50
+ @daemonize && !@skip_daemonize
51
+ end
52
+
53
+ def restart?
54
+ @restart
55
+ end
56
+
57
+ private
58
+
59
+ def log(message)
60
+ @logger.info "[Qs] #{message}"
61
+ end
62
+
63
+ def run_restart_cmd
64
+ log "Restarting #{@daemon.name} daemon..."
65
+ ENV['QS_SKIP_DAEMONIZE'] = 'yes'
66
+ @restart_cmd.run
67
+ end
68
+
69
+ def default_if_blank(value, default, &block)
70
+ ignore_if_blank(value, &block) || default
71
+ end
72
+
73
+ def ignore_if_blank(value, &block)
74
+ block ||= proc{ |v| v }
75
+ block.call(value) if value && !value.empty?
76
+ end
77
+
78
+ end
79
+
80
+ class RestartCmd
81
+ attr_reader :argv, :dir
82
+
83
+ def initialize
84
+ require 'rubygems'
85
+ @dir = get_pwd
86
+ @argv = [Gem.ruby, $0, ARGV.dup].flatten
87
+ end
88
+
89
+ def run
90
+ Dir.chdir self.dir
91
+ Kernel.exec(*self.argv)
92
+ end
93
+
94
+ private
95
+
96
+ # Trick from puma/unicorn. Favor PWD because it contains an unresolved
97
+ # symlink. This is useful when restarting after deploying; the original
98
+ # directory may be removed, but the symlink is pointing to a new
99
+ # directory.
100
+ def get_pwd
101
+ return Dir.pwd if ENV['PWD'].nil?
102
+ env_stat = File.stat(ENV['PWD'])
103
+ pwd_stat = File.stat(Dir.pwd)
104
+ if env_stat.ino == pwd_stat.ino && env_stat.dev == pwd_stat.dev
105
+ ENV['PWD']
106
+ else
107
+ Dir.pwd
108
+ end
109
+ end
110
+ end
111
+
112
+ # This is from puma for 1.8 compatibility. Ruby 1.9+ defines a
113
+ # `Process.daemon` for daemonizing processes. This defines the method when it
114
+ # isn't provided, i.e. Ruby 1.8.
115
+ unless ::Process.respond_to?(:daemon)
116
+ ::Process.class_eval do
117
+
118
+ # Full explanation: http://www.steve.org.uk/Reference/Unix/faq_2.html#SEC16
119
+ def self.daemon(no_chdir = false, no_close = false)
120
+ exit if fork
121
+ ::Process.setsid
122
+ exit if fork
123
+ Dir.chdir '/' unless no_chdir
124
+ if !no_close
125
+ null = File.open('/dev/null', 'w')
126
+ STDIN.reopen null
127
+ STDOUT.reopen null
128
+ STDERR.reopen null
129
+ end
130
+ return 0
131
+ end
132
+
133
+ end
134
+ end
135
+
136
+ end
@@ -0,0 +1,20 @@
1
+ require 'qs/pid_file'
2
+
3
+ module Qs
4
+
5
+ class ProcessSignal
6
+
7
+ attr_reader :signal, :pid
8
+
9
+ def initialize(daemon, signal)
10
+ @signal = signal
11
+ @pid = PIDFile.new(daemon.pid_file).pid
12
+ end
13
+
14
+ def send
15
+ ::Process.kill(@signal, @pid)
16
+ end
17
+
18
+ end
19
+
20
+ end
@@ -0,0 +1,49 @@
1
+ require 'system_timer'
2
+ require 'qs'
3
+ require 'qs/runner'
4
+
5
+ module Qs
6
+
7
+ class QsRunner < Runner
8
+
9
+ attr_reader :timeout
10
+
11
+ def initialize(handler_class, args = nil)
12
+ super(handler_class, args)
13
+ @timeout = handler_class.timeout || Qs.config.timeout
14
+ end
15
+
16
+ def run
17
+ OptionalTimeout.new(self.timeout) do
18
+ run_callbacks self.handler_class.before_callbacks
19
+ self.handler.init
20
+ self.handler.run
21
+ run_callbacks self.handler_class.after_callbacks
22
+ end
23
+ rescue TimeoutError => exception
24
+ error = TimeoutError.new "#{handler_class} timed out (#{timeout}s)"
25
+ error.set_backtrace(exception.backtrace)
26
+ raise error
27
+ end
28
+
29
+ private
30
+
31
+ def run_callbacks(callbacks)
32
+ callbacks.each{ |proc| self.handler.instance_eval(&proc) }
33
+ end
34
+
35
+ module OptionalTimeout
36
+ def self.new(timeout, &block)
37
+ if !timeout.nil?
38
+ SystemTimer.timeout_after(timeout, TimeoutError, &block)
39
+ else
40
+ block.call
41
+ end
42
+ end
43
+ end
44
+
45
+ end
46
+
47
+ TimeoutError = Class.new(RuntimeError)
48
+
49
+ end
data/lib/qs/queue.rb ADDED
@@ -0,0 +1,69 @@
1
+ require 'qs/route'
2
+
3
+ module Qs
4
+
5
+ class Queue
6
+
7
+ attr_reader :routes
8
+ attr_reader :enqueued_jobs
9
+
10
+ def initialize(&block)
11
+ @routes = []
12
+ @enqueued_jobs = []
13
+ self.instance_eval(&block) if !block.nil?
14
+ raise InvalidError, "a queue must have a name" if self.name.nil?
15
+ end
16
+
17
+ def name(value = nil)
18
+ @name = value if !value.nil?
19
+ @name
20
+ end
21
+
22
+ def redis_key
23
+ @redis_key ||= RedisKey.new(self.name)
24
+ end
25
+
26
+ def job_handler_ns(value = nil)
27
+ @job_handler_ns = value if !value.nil?
28
+ @job_handler_ns
29
+ end
30
+
31
+ def job(name, handler_name)
32
+ if self.job_handler_ns && !(handler_name =~ /^::/)
33
+ handler_name = "#{self.job_handler_ns}::#{handler_name}"
34
+ end
35
+
36
+ @routes.push(Qs::Route.new(name, handler_name))
37
+ end
38
+
39
+ def enqueue(job_name, params = nil)
40
+ Qs.enqueue(self, job_name, params)
41
+ end
42
+ alias :add :enqueue
43
+
44
+ def reset!
45
+ self.enqueued_jobs.clear
46
+ end
47
+
48
+ def inspect
49
+ reference = '0x0%x' % (self.object_id << 1)
50
+ "#<#{self.class}:#{reference} " \
51
+ "@name=#{self.name.inspect} " \
52
+ "@job_handler_ns=#{self.job_handler_ns.inspect}>"
53
+ end
54
+
55
+ InvalidError = Class.new(RuntimeError)
56
+
57
+ module RedisKey
58
+ def self.parse_name(key)
59
+ key.split(':').last
60
+ end
61
+
62
+ def self.new(name)
63
+ "queues:#{name}"
64
+ end
65
+ end
66
+
67
+ end
68
+
69
+ end
@@ -0,0 +1,33 @@
1
+ module Qs
2
+
3
+ class RedisItem
4
+
5
+ attr_reader :queue_redis_key, :serialized_payload
6
+ attr_accessor :started, :finished
7
+ attr_accessor :job, :handler_class
8
+ attr_accessor :exception, :time_taken
9
+
10
+ def initialize(queue_redis_key, serialized_payload)
11
+ @queue_redis_key = queue_redis_key
12
+ @serialized_payload = serialized_payload
13
+ @started = false
14
+ @finished = false
15
+
16
+ @job = nil
17
+ @handler_class = nil
18
+ @exception = nil
19
+ @time_taken = nil
20
+ end
21
+
22
+ def ==(other)
23
+ if other.kind_of?(self.class)
24
+ self.queue_redis_key == other.queue_redis_key &&
25
+ self.serialized_payload == other.serialized_payload
26
+ else
27
+ super
28
+ end
29
+ end
30
+
31
+ end
32
+
33
+ end
data/lib/qs/route.rb ADDED
@@ -0,0 +1,52 @@
1
+ require 'qs/qs_runner'
2
+
3
+ module Qs
4
+
5
+ class Route
6
+
7
+ attr_reader :name, :handler_class_name, :handler_class
8
+
9
+ def initialize(name, handler_class_name)
10
+ @name = name.to_s
11
+ @handler_class_name = handler_class_name
12
+ @handler_class = nil
13
+ end
14
+
15
+ def validate!
16
+ @handler_class = constantize_handler_class(@handler_class_name)
17
+ end
18
+
19
+ def run(job, daemon_data)
20
+ QsRunner.new(self.handler_class, {
21
+ :job => job,
22
+ :params => job.params,
23
+ :logger => daemon_data.logger
24
+ }).run
25
+ end
26
+
27
+ private
28
+
29
+ def constantize_handler_class(handler_class_name)
30
+ constantize(handler_class_name).tap do |handler_class|
31
+ raise(NoHandlerClassError.new(handler_class_name)) if !handler_class
32
+ end
33
+ end
34
+
35
+ def constantize(class_name)
36
+ names = class_name.to_s.split('::').reject{ |name| name.empty? }
37
+ klass = names.inject(Object){ |constant, name| constant.const_get(name) }
38
+ klass == Object ? false : klass
39
+ rescue NameError
40
+ false
41
+ end
42
+
43
+ end
44
+
45
+ class NoHandlerClassError < RuntimeError
46
+ def initialize(handler_class_name)
47
+ super "Qs couldn't find the handler '#{handler_class_name}'" \
48
+ " - it doesn't exist or hasn't been required in yet."
49
+ end
50
+ end
51
+
52
+ end
data/lib/qs/runner.rb ADDED
@@ -0,0 +1,26 @@
1
+ require 'qs/logger'
2
+
3
+ module Qs
4
+
5
+ class Runner
6
+
7
+ attr_reader :handler_class, :handler
8
+ attr_reader :job, :params, :logger
9
+
10
+ def initialize(handler_class, args = nil)
11
+ @handler_class = handler_class
12
+ @handler = @handler_class.new(self)
13
+
14
+ a = args || {}
15
+ @job = a[:job]
16
+ @params = a[:params] || {}
17
+ @logger = a[:logger] || Qs::NullLogger.new
18
+ end
19
+
20
+ def run
21
+ raise NotImplementedError
22
+ end
23
+
24
+ end
25
+
26
+ end
@@ -0,0 +1,17 @@
1
+ require 'qs/test_runner'
2
+
3
+ module Qs
4
+
5
+ module TestHelpers
6
+
7
+ def test_runner(handler_class, args = nil)
8
+ TestRunner.new(handler_class, args)
9
+ end
10
+
11
+ def test_handler(handler_class, args = nil)
12
+ test_runner(handler_class, args).handler
13
+ end
14
+
15
+ end
16
+
17
+ end
@@ -0,0 +1,43 @@
1
+ require 'qs'
2
+ require 'qs/job'
3
+ require 'qs/job_handler'
4
+ require 'qs/runner'
5
+
6
+ module Qs
7
+
8
+ InvalidJobHandlerError = Class.new(StandardError)
9
+
10
+ class TestRunner < Runner
11
+
12
+ def initialize(handler_class, args = nil)
13
+ if !handler_class.include?(Qs::JobHandler)
14
+ raise InvalidJobHandlerError, "#{handler_class.inspect} is not a"\
15
+ " Qs::JobHandler"
16
+ end
17
+ args = (args || {}).dup
18
+ super(handler_class, {
19
+ :job => args.delete(:job),
20
+ :params => normalize_params(args.delete(:params) || {}),
21
+ :logger => args.delete(:logger)
22
+ })
23
+ args.each{ |key, value| self.handler.send("#{key}=", value) }
24
+
25
+ self.handler.init
26
+ end
27
+
28
+ def run
29
+ self.handler.run
30
+ end
31
+
32
+ private
33
+
34
+ # Stringify and serialize/deserialize to ensure params are valid and are
35
+ # in the format they would normally be when a handler is built and run.
36
+ def normalize_params(params)
37
+ params = Job::StringifyParams.new(params)
38
+ Qs.deserialize(Qs.serialize(params))
39
+ end
40
+
41
+ end
42
+
43
+ end
data/lib/qs/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Qs
2
- VERSION = "0.0.1"
2
+ VERSION = "0.1.0"
3
3
  end