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
data/.gitignore CHANGED
@@ -1,6 +1,7 @@
1
1
  *.gem
2
2
  *.log
3
3
  *.rbc
4
+ .rbx/
4
5
  .bundle
5
6
  .config
6
7
  .yardoc
data/Gemfile CHANGED
@@ -1,5 +1,10 @@
1
- source "https://rubygems.org"
1
+ source 'https://rubygems.org'
2
2
 
3
3
  gemspec
4
4
 
5
5
  gem 'rake'
6
+ gem 'pry', "~> 0.9.0"
7
+
8
+ platform :ruby_18 do
9
+ gem 'json', '~> 1.8'
10
+ end
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2010-Present Kelly Redding and Collin Redding
1
+ Copyright (c) 2013-Present Kelly Redding and Collin Redding
2
2
 
3
3
  MIT License
4
4
 
data/bench/config.qs ADDED
@@ -0,0 +1,46 @@
1
+ require 'qs'
2
+ require 'bench/queue'
3
+
4
+ ROOT_PATH = Pathname.new(File.expand_path('../..', __FILE__))
5
+
6
+ LOGGER = if ENV['BENCH_REPORT']
7
+ Logger.new(ROOT_PATH.join('log/bench_daemon.log').to_s)
8
+ else
9
+ Logger.new(STDOUT)
10
+ end
11
+
12
+ PROGRESS_IO = if ENV['BENCH_PROGRESS_IO']
13
+ ::IO.for_fd(ENV['BENCH_PROGRESS_IO'].to_i)
14
+ else
15
+ File.open('/dev/null', 'w')
16
+ end
17
+
18
+ class BenchDaemon
19
+ include Qs::Daemon
20
+
21
+ name 'bench'
22
+ pid_file ROOT_PATH.join('tmp/bench_daemon.pid').to_s
23
+
24
+ logger LOGGER
25
+ verbose_logging false
26
+
27
+ queue BenchQueue
28
+
29
+ # if jobs fail notify the bench report so it doesn't hang forever on IO.select
30
+ error do |exception, daemon_data, job|
31
+ PROGRESS_IO.write_nonblock('F')
32
+ end
33
+
34
+ class Multiply
35
+ include Qs::JobHandler
36
+
37
+ after{ PROGRESS_IO.write_nonblock('.') }
38
+
39
+ def run!
40
+ 'a' * params['size']
41
+ end
42
+ end
43
+
44
+ end
45
+
46
+ run BenchDaemon.new
data/bench/queue.rb ADDED
@@ -0,0 +1,8 @@
1
+ require 'qs'
2
+ require 'json'
3
+
4
+ BenchQueue = Qs::Queue.new do
5
+ name 'bench'
6
+
7
+ job 'multiply', 'BenchDaemon::Multiply'
8
+ end
data/bench/report.rb ADDED
@@ -0,0 +1,114 @@
1
+ require 'benchmark'
2
+ require 'scmd'
3
+ require 'bench/queue'
4
+
5
+ class BenchRunner
6
+
7
+ TIME_MODIFIER = 10 ** 4 # 4 decimal places
8
+
9
+ def initialize
10
+ output_file_path = if ENV['OUTPUT_FILE']
11
+ File.expand_path(ENV['OUTPUT_FILE'])
12
+ else
13
+ File.expand_path('../report.txt', __FILE__)
14
+ end
15
+ @output_file = File.open(output_file_path, 'w')
16
+
17
+ @number_of_jobs = ENV['NUM_JOBS'] || 10_000
18
+ @job_name = 'multiply'
19
+ @job_params = { 'size' => 100_000 }
20
+
21
+ @progress_reader, @progress_writer = IO.pipe
22
+
23
+ @results = {}
24
+
25
+ Qs.init
26
+ end
27
+
28
+ def run
29
+ output "Running benchmark report..."
30
+ output("\n", false)
31
+
32
+ benchmark_adding_jobs
33
+ benchmark_running_jobs
34
+
35
+ size = @results.values.map(&:size).max
36
+ output "Adding #{@number_of_jobs} Jobs Time: #{@results[:adding_jobs].rjust(size)}s"
37
+ output "Running #{@number_of_jobs} Jobs Time: #{@results[:running_jobs].rjust(size)}s"
38
+
39
+ output "\n"
40
+ output "Done running benchmark report"
41
+ end
42
+
43
+ private
44
+
45
+ def benchmark_adding_jobs
46
+ output "Adding jobs"
47
+ benchmark = Benchmark.measure do
48
+ (1..@number_of_jobs).each do |n|
49
+ BenchQueue.add(@job_name, @job_params)
50
+ output('.', false) if ((n - 1) % 100 == 0)
51
+ end
52
+ end
53
+ @results[:adding_jobs] = round_and_display(benchmark.real)
54
+ output("\n", false)
55
+ end
56
+
57
+ def benchmark_running_jobs
58
+ cmd_str = "bundle exec ./bin/qs bench/config.qs"
59
+ cmd = Scmd.new(cmd_str, {
60
+ 'BENCH_REPORT' => 'yes',
61
+ 'BENCH_PROGRESS_IO' => @progress_writer.fileno
62
+ })
63
+
64
+ output "Running jobs"
65
+ begin
66
+ benchmark = Benchmark.measure do
67
+ cmd.start
68
+ if !cmd.running?
69
+ raise "failed to start qs process: #{cmd_str.inspect}"
70
+ end
71
+
72
+ progress = 0
73
+ while progress < @number_of_jobs
74
+ ::IO.select([@progress_reader])
75
+ result = @progress_reader.read_nonblock(1)
76
+ progress += 1
77
+ output(result, false) if ((progress - 1) % 100 == 0)
78
+ end
79
+ end
80
+ @results[:running_jobs] = round_and_display(benchmark.real)
81
+ output("\n", false)
82
+ ensure
83
+ cmd.kill('TERM')
84
+ cmd.wait(5)
85
+ end
86
+
87
+ output("\n", false)
88
+ end
89
+
90
+ private
91
+
92
+ def output(message, puts = true)
93
+ method = puts ? :puts : :print
94
+ self.send(method, message)
95
+ @output_file.send(method, message)
96
+ STDOUT.flush if method == :print
97
+ end
98
+
99
+ def round_and_display(time_in_ms)
100
+ display_time(round_time(time_in_ms))
101
+ end
102
+
103
+ def round_time(time_in_ms)
104
+ (time_in_ms * TIME_MODIFIER).to_i / TIME_MODIFIER.to_f
105
+ end
106
+
107
+ def display_time(time)
108
+ integer, fractional = time.to_s.split('.')
109
+ [ integer, fractional.ljust(4, '0') ].join('.')
110
+ end
111
+
112
+ end
113
+
114
+ BenchRunner.new.run
data/bench/report.txt ADDED
@@ -0,0 +1,11 @@
1
+ Running benchmark report...
2
+
3
+ Adding jobs
4
+ ....................................................................................................
5
+ Running jobs
6
+ ....................................................................................................
7
+
8
+ Adding 10000 Jobs Time: 1.6041s
9
+ Running 10000 Jobs Time: 12.3159s
10
+
11
+ Done running benchmark report
data/bin/qs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # Copyright (c) 2013 Collin Redding & Kelly Redding
4
+ #
5
+
6
+ require 'qs/cli'
7
+ Qs::CLI.run ARGV
data/lib/qs/cli.rb ADDED
@@ -0,0 +1,124 @@
1
+ require 'qs'
2
+ require 'qs/config_file'
3
+ require 'qs/process'
4
+ require 'qs/process_signal'
5
+ require 'qs/version'
6
+
7
+ module Qs
8
+
9
+ class CLI
10
+
11
+ def self.run(args)
12
+ self.new.run(*args)
13
+ end
14
+
15
+ def initialize(kernel = nil)
16
+ @kernel = kernel || Kernel
17
+ @cli = CLIRB.new
18
+ end
19
+
20
+ def run(*args)
21
+ begin
22
+ run!(*args)
23
+ rescue CLIRB::HelpExit
24
+ @kernel.puts help
25
+ rescue CLIRB::VersionExit
26
+ @kernel.puts Qs::VERSION
27
+ rescue CLIRB::Error, Qs::ConfigFile::InvalidError => exception
28
+ @kernel.puts "#{exception.message}\n\n"
29
+ @kernel.puts help
30
+ @kernel.exit 1
31
+ rescue StandardError => exception
32
+ @kernel.puts "#{exception.class}: #{exception.message}"
33
+ @kernel.puts exception.backtrace.join("\n")
34
+ @kernel.exit 1
35
+ end
36
+ @kernel.exit 0
37
+ end
38
+
39
+ private
40
+
41
+ def run!(*args)
42
+ @cli.parse!(args)
43
+ config_file_path, command = @cli.args
44
+ config_file_path ||= 'config.qs'
45
+ command ||= 'run'
46
+ daemon = Qs::ConfigFile.new(config_file_path).daemon
47
+ case(command)
48
+ when 'run'
49
+ Qs::Process.new(daemon, :daemonize => false).run
50
+ when 'start'
51
+ Qs::Process.new(daemon, :daemonize => true).run
52
+ when 'stop'
53
+ Qs::ProcessSignal.new(daemon, 'TERM').send
54
+ when 'restart'
55
+ Qs::ProcessSignal.new(daemon, 'USR2').send
56
+ else
57
+ raise CLIRB::Error, "#{command.inspect} is not a valid command"
58
+ end
59
+ end
60
+
61
+ def help
62
+ "Usage: qs [CONFIG_FILE] [COMMAND]\n\n" \
63
+ "Commands: run, start, stop, restart\n" \
64
+ "Options: #{@cli}"
65
+ end
66
+
67
+ end
68
+
69
+ class CLIRB # Version 1.0.0, https://github.com/redding/cli.rb
70
+ Error = Class.new(RuntimeError);
71
+ HelpExit = Class.new(RuntimeError); VersionExit = Class.new(RuntimeError)
72
+ attr_reader :argv, :args, :opts, :data
73
+
74
+ def initialize(&block)
75
+ @options = []; instance_eval(&block) if block
76
+ require 'optparse'
77
+ @data, @args, @opts = [], [], {}; @parser = OptionParser.new do |p|
78
+ p.banner = ''; @options.each do |o|
79
+ @opts[o.name] = o.value; p.on(*o.parser_args){ |v| @opts[o.name] = v }
80
+ end
81
+ p.on_tail('--version', ''){ |v| raise VersionExit, v.to_s }
82
+ p.on_tail('--help', ''){ |v| raise HelpExit, v.to_s }
83
+ end
84
+ end
85
+
86
+ def option(*args); @options << Option.new(*args); end
87
+ def parse!(argv)
88
+ @args = (argv || []).dup.tap do |args_list|
89
+ begin; @parser.parse!(args_list)
90
+ rescue OptionParser::ParseError => err; raise Error, err.message; end
91
+ end; @data = @args + [@opts]
92
+ end
93
+ def to_s; @parser.to_s; end
94
+ def inspect
95
+ "#<#{self.class}:#{'0x0%x' % (object_id << 1)} @data=#{@data.inspect}>"
96
+ end
97
+
98
+ class Option
99
+ attr_reader :name, :opt_name, :desc, :abbrev, :value, :klass, :parser_args
100
+
101
+ def initialize(name, *args)
102
+ settings, @desc = args.last.kind_of?(::Hash) ? args.pop : {}, args.pop || ''
103
+ @name, @opt_name, @abbrev = parse_name_values(name, settings[:abbrev])
104
+ @value, @klass = gvalinfo(settings[:value])
105
+ @parser_args = if [TrueClass, FalseClass, NilClass].include?(@klass)
106
+ ["-#{@abbrev}", "--[no-]#{@opt_name}", @desc]
107
+ else
108
+ ["-#{@abbrev}", "--#{@opt_name} #{@opt_name.upcase}", @klass, @desc]
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def parse_name_values(name, custom_abbrev)
115
+ [ (processed_name = name.to_s.strip.downcase), processed_name.gsub('_', '-'),
116
+ custom_abbrev || processed_name.gsub(/[^a-z]/, '').chars.first || 'a'
117
+ ]
118
+ end
119
+ def gvalinfo(v); v.kind_of?(Class) ? [nil,gklass(v)] : [v,gklass(v.class)]; end
120
+ def gklass(k); k == Fixnum ? Integer : k; end
121
+ end
122
+ end
123
+
124
+ end
data/lib/qs/client.rb ADDED
@@ -0,0 +1,121 @@
1
+ require 'hella-redis'
2
+ require 'qs'
3
+ require 'qs/job'
4
+ require 'qs/queue'
5
+
6
+ module Qs
7
+
8
+ module Client
9
+
10
+ def self.new(*args)
11
+ if !ENV['QS_TEST_MODE']
12
+ QsClient.new(*args)
13
+ else
14
+ TestClient.new(*args)
15
+ end
16
+ end
17
+
18
+ def self.included(klass)
19
+ klass.class_eval do
20
+ include InstanceMethods
21
+ end
22
+ end
23
+
24
+ module InstanceMethods
25
+
26
+ attr_reader :redis_config, :redis
27
+
28
+ def initialize(redis_config)
29
+ @redis_config = redis_config
30
+ end
31
+
32
+ def enqueue(queue, job_name, params = nil)
33
+ job = Qs::Job.new(job_name, params || {})
34
+ enqueue!(queue, job)
35
+ job
36
+ end
37
+
38
+ def push(queue_name, payload)
39
+ raise NotImplementedError
40
+ end
41
+
42
+ def block_dequeue(*args)
43
+ self.redis.with{ |c| c.brpop(*args) }
44
+ end
45
+
46
+ def append(queue_redis_key, serialized_payload)
47
+ self.redis.with{ |c| c.lpush(queue_redis_key, serialized_payload) }
48
+ end
49
+
50
+ def prepend(queue_redis_key, serialized_payload)
51
+ self.redis.with{ |c| c.rpush(queue_redis_key, serialized_payload) }
52
+ end
53
+
54
+ def clear(redis_key)
55
+ self.redis.with{ |c| c.del(redis_key) }
56
+ end
57
+
58
+ end
59
+
60
+ end
61
+
62
+ class QsClient
63
+ include Client
64
+
65
+ def initialize(*args)
66
+ super
67
+ @redis = HellaRedis::Connection.new(self.redis_config)
68
+ end
69
+
70
+ def push(queue_name, payload)
71
+ queue_redis_key = Queue::RedisKey.new(queue_name)
72
+ serialized_payload = Qs.serialize(payload)
73
+ self.append(queue_redis_key, serialized_payload)
74
+ end
75
+
76
+ private
77
+
78
+ def enqueue!(queue, job)
79
+ serialized_payload = Qs.serialize(job.to_payload)
80
+ self.append(queue.redis_key, serialized_payload)
81
+ end
82
+
83
+ end
84
+
85
+ class TestClient
86
+ include Client
87
+
88
+ attr_reader :pushed_items
89
+
90
+ def initialize(*args)
91
+ super
92
+ require 'hella-redis/connection_spy'
93
+ @redis = HellaRedis::ConnectionSpy.new(self.redis_config)
94
+ @pushed_items = []
95
+ end
96
+
97
+ def push(queue_name, payload)
98
+ # attempt to serialize (and then throw away) the payload, this will error
99
+ # on the developer if it can't be serialized
100
+ Qs.serialize(payload)
101
+ @pushed_items << PushedItem.new(queue_name, payload)
102
+ end
103
+
104
+ def reset!
105
+ @pushed_items.clear
106
+ end
107
+
108
+ private
109
+
110
+ def enqueue!(queue, job)
111
+ # attempt to serialize (and then throw away) the job payload, this will
112
+ # error on the developer if it can't serialize the job
113
+ Qs.serialize(job.to_payload)
114
+ queue.enqueued_jobs << job
115
+ end
116
+
117
+ PushedItem = Struct.new(:queue_name, :payload)
118
+
119
+ end
120
+
121
+ end
@@ -0,0 +1,79 @@
1
+ require 'qs/daemon'
2
+
3
+ module Qs
4
+
5
+ class ConfigFile
6
+
7
+ attr_reader :daemon
8
+
9
+ def initialize(file_path)
10
+ @file_path = build_file_path(file_path)
11
+ @daemon = nil
12
+ evaluate_file(@file_path)
13
+ validate!
14
+ end
15
+
16
+ def run(daemon)
17
+ @daemon = daemon
18
+ end
19
+
20
+ private
21
+
22
+ def validate!
23
+ if !@daemon.kind_of?(Qs::Daemon)
24
+ raise NoDaemonError.new(@daemon, @file_path)
25
+ end
26
+ end
27
+
28
+ def build_file_path(path)
29
+ full_path = File.expand_path(path)
30
+ raise NoConfigFileError.new(full_path) unless File.exists?(full_path)
31
+ full_path
32
+ rescue NoConfigFileError
33
+ full_path_with_sanford = "#{full_path}.qs"
34
+ raise unless File.exists?(full_path_with_sanford)
35
+ full_path_with_sanford
36
+ end
37
+
38
+ # This evaluates the file and creates a proc using it's contents. This is
39
+ # a trick borrowed from Rack. It is essentially converting a file into a
40
+ # proc and then instance eval'ing it. This has a couple benefits:
41
+ # * The obvious benefit is the file is evaluated in the context of this
42
+ # class. This allows the file to call `run`, setting the daemon that
43
+ # will be used.
44
+ # * The other benefit is that the file's contents behave like they were a
45
+ # proc defined by the user. Instance eval'ing the file directly, makes
46
+ # any constants (modules/classes) defined in it namespaced by the
47
+ # instance of the config (not namespaced by `Qs::ConfigFile`,
48
+ # they are actually namespaced by an instance of this class, its like
49
+ # accessing it via `ConfigFile.new::MyDaemon`), which is very confusing.
50
+ # Thus, the proc is created and eval'd using the `TOPLEVEL_BINDING`,
51
+ # which defines the constants at the top-level, as would be expected.
52
+ def evaluate_file(file_path)
53
+ config_file_code = "proc{ #{File.read(file_path)} }"
54
+ config_file_proc = eval(config_file_code, TOPLEVEL_BINDING, file_path, 0)
55
+ self.instance_eval(&config_file_proc)
56
+ end
57
+
58
+ InvalidError = Class.new(StandardError)
59
+
60
+ class NoConfigFileError < InvalidError
61
+ def initialize(path)
62
+ super "A configuration file couldn't be found at: #{path.to_s.inspect}"
63
+ end
64
+ end
65
+
66
+ class NoDaemonError < InvalidError
67
+ def initialize(daemon, path)
68
+ prefix = "Configuration file #{path.to_s.inspect}"
69
+ if daemon
70
+ super "#{prefix} called `run` without a Qs::Daemon"
71
+ else
72
+ super "#{prefix} didn't call `run` with a Qs::Daemon"
73
+ end
74
+ end
75
+ end
76
+
77
+ end
78
+
79
+ end