qs 0.0.1 → 0.1.0

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