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
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