jiggler 0.1.0.rc2

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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE +4 -0
  4. data/README.md +423 -0
  5. data/bin/jiggler +31 -0
  6. data/lib/jiggler/cleaner.rb +130 -0
  7. data/lib/jiggler/cli.rb +263 -0
  8. data/lib/jiggler/config.rb +165 -0
  9. data/lib/jiggler/core.rb +22 -0
  10. data/lib/jiggler/errors.rb +5 -0
  11. data/lib/jiggler/job.rb +116 -0
  12. data/lib/jiggler/launcher.rb +69 -0
  13. data/lib/jiggler/manager.rb +73 -0
  14. data/lib/jiggler/redis_store.rb +55 -0
  15. data/lib/jiggler/retrier.rb +122 -0
  16. data/lib/jiggler/scheduled/enqueuer.rb +78 -0
  17. data/lib/jiggler/scheduled/poller.rb +97 -0
  18. data/lib/jiggler/stats/collection.rb +26 -0
  19. data/lib/jiggler/stats/monitor.rb +103 -0
  20. data/lib/jiggler/summary.rb +101 -0
  21. data/lib/jiggler/support/helper.rb +35 -0
  22. data/lib/jiggler/version.rb +5 -0
  23. data/lib/jiggler/web/assets/stylesheets/application.css +64 -0
  24. data/lib/jiggler/web/views/application.erb +329 -0
  25. data/lib/jiggler/web.rb +80 -0
  26. data/lib/jiggler/worker.rb +179 -0
  27. data/lib/jiggler.rb +10 -0
  28. data/spec/examples.txt +79 -0
  29. data/spec/fixtures/config/jiggler.yml +4 -0
  30. data/spec/fixtures/jobs.rb +5 -0
  31. data/spec/fixtures/my_failed_job.rb +10 -0
  32. data/spec/fixtures/my_job.rb +9 -0
  33. data/spec/fixtures/my_job_with_args.rb +18 -0
  34. data/spec/jiggler/cleaner_spec.rb +171 -0
  35. data/spec/jiggler/cli_spec.rb +87 -0
  36. data/spec/jiggler/config_spec.rb +56 -0
  37. data/spec/jiggler/core_spec.rb +34 -0
  38. data/spec/jiggler/job_spec.rb +99 -0
  39. data/spec/jiggler/launcher_spec.rb +66 -0
  40. data/spec/jiggler/manager_spec.rb +52 -0
  41. data/spec/jiggler/redis_store_spec.rb +20 -0
  42. data/spec/jiggler/retrier_spec.rb +55 -0
  43. data/spec/jiggler/scheduled/enqueuer_spec.rb +81 -0
  44. data/spec/jiggler/scheduled/poller_spec.rb +40 -0
  45. data/spec/jiggler/stats/monitor_spec.rb +40 -0
  46. data/spec/jiggler/summary_spec.rb +168 -0
  47. data/spec/jiggler/web_spec.rb +37 -0
  48. data/spec/jiggler/worker_spec.rb +110 -0
  49. data/spec/spec_helper.rb +54 -0
  50. metadata +230 -0
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'optparse'
5
+ require 'yaml'
6
+ require 'async'
7
+ require 'async/io/trap'
8
+ require 'async/pool'
9
+
10
+ module Jiggler
11
+ class CLI
12
+ include Singleton
13
+ CONTEXT_SWITCHER_THRESHOLD = 0.5
14
+
15
+ attr_reader :logger, :config, :environment
16
+
17
+ SIGNAL_HANDLERS = {
18
+ :INT => ->(cli) {
19
+ cli.logger.fatal('Received INT, shutting down')
20
+ cli.stop
21
+ },
22
+ :TERM => ->(cli) {
23
+ cli.logger.fatal('Received TERM, shutting down')
24
+ cli.stop
25
+ },
26
+ :TSTP => ->(cli) {
27
+ cli.logger.info('Received TSTP, no longer accepting new work')
28
+ cli.suspend
29
+ }
30
+ }
31
+ UNHANDLED_SIGNAL_HANDLER = ->(cli) { cli.logger.info('No signal handler registered, ignoring') }
32
+ SIGNAL_HANDLERS.default = UNHANDLED_SIGNAL_HANDLER
33
+ SIGNAL_HANDLERS.freeze
34
+
35
+ def parse_and_init(args = ARGV.dup)
36
+ @config ||= Jiggler.config
37
+
38
+ setup_options(args)
39
+ initialize_logger
40
+ validate!
41
+ load_app
42
+ end
43
+
44
+ def start
45
+ return unless ping_redis
46
+ @cond = Async::Condition.new
47
+ Async do
48
+ setup_signal_handlers
49
+ patch_scheduler
50
+ @launcher = Launcher.new(config)
51
+ @launcher.start
52
+ Async do
53
+ @cond.wait
54
+ end
55
+ end
56
+ @switcher&.exit
57
+ end
58
+
59
+ def stop
60
+ @launcher.stop
61
+ logger.info('Jiggler is stopped, bye!')
62
+ @cond.signal
63
+ end
64
+
65
+ def suspend
66
+ @launcher.suspend
67
+ logger.info('Jiggler is suspended')
68
+ end
69
+
70
+ private
71
+
72
+ # forces scheduler to switch fibers if they take more than threshold to execute
73
+ def patch_scheduler
74
+ @switcher = Thread.new(Fiber.scheduler) do |scheduler|
75
+ loop do
76
+ sleep(CONTEXT_SWITCHER_THRESHOLD)
77
+ switch = scheduler.context_switch
78
+ next if switch.nil?
79
+ next if Process.clock_gettime(Process::CLOCK_MONOTONIC) - switch < CONTEXT_SWITCHER_THRESHOLD
80
+
81
+ Process.kill('URG', Process.pid)
82
+ end
83
+ end
84
+
85
+ Signal.trap('URG') do
86
+ next Fiber.scheduler.context_switch!(nil) unless Async::Task.current?
87
+ Async::Task.current.yield
88
+ end
89
+
90
+ Fiber.scheduler.instance_eval do
91
+ def context_switch
92
+ @context_switch
93
+ end
94
+
95
+ def context_switch!(value = Process.clock_gettime(Process::CLOCK_MONOTONIC))
96
+ @context_switch = value
97
+ end
98
+
99
+ def block(...)
100
+ context_switch!(nil)
101
+ super
102
+ end
103
+
104
+ def kernel_sleep(...)
105
+ context_switch!(nil)
106
+ super
107
+ end
108
+
109
+ def resume(fiber, *args)
110
+ context_switch!
111
+ super
112
+ end
113
+ end
114
+ end
115
+
116
+ def setup_signal_handlers
117
+ SIGNAL_HANDLERS.each do |signal, handler|
118
+ trap = Async::IO::Trap.new(signal)
119
+ trap.install!
120
+ Async(transient: true) do
121
+ trap.wait
122
+ invoked_traps[signal] += 1
123
+ handler.call(self)
124
+ end
125
+ end
126
+ end
127
+
128
+ def invoked_traps
129
+ @invoked_traps ||= Hash.new { |h, k| h[k] = 0 }
130
+ end
131
+
132
+ def validate!
133
+ if config[:queues].any? { |q| q.include?(':') }
134
+ raise ArgumentError, 'Queue names cannot contain colons'
135
+ end
136
+
137
+ [:concurrency, :client_concurrency, :timeout].each do |opt|
138
+ raise ArgumentError, "#{opt}: #{config[opt]} is not a valid value" if config[opt].to_i <= 0
139
+ end
140
+ end
141
+
142
+ def parse_options(argv)
143
+ opts = {}
144
+ @parser = option_parser(opts)
145
+ @parser.parse!(argv)
146
+ opts
147
+ end
148
+
149
+ def option_parser(opts)
150
+ parser = OptionParser.new do |o|
151
+ o.on '-c', '--concurrency INT', 'Number of fibers to use on the server' do |arg|
152
+ opts[:concurrency] = Integer(arg)
153
+ end
154
+
155
+ o.on '-e', '--environment ENV', 'Application environment' do |arg|
156
+ opts[:environment] = arg
157
+ end
158
+
159
+ o.on '-q', '--queue QUEUE1,QUEUE2', 'Queues to process' do |arg|
160
+ opts[:queues] ||= []
161
+ arg.split(',').each do |queue|
162
+ opts[:queues] << queue
163
+ end
164
+ end
165
+
166
+ o.on '-r', '--require PATH', 'File to require' do |arg|
167
+ opts[:require] = arg
168
+ end
169
+
170
+ o.on '-t', '--timeout NUM', 'Shutdown timeout' do |arg|
171
+ opts[:timeout] = Integer(arg)
172
+ end
173
+
174
+ o.on '-v', '--verbose', 'Print more verbose output' do |arg|
175
+ opts[:verbose] = arg
176
+ end
177
+
178
+ o.on '-C', '--config PATH', 'Path to YAML config file' do |arg|
179
+ opts[:config_file] = arg
180
+ end
181
+
182
+ o.on '-V', '--version', 'Print version and exit' do
183
+ puts("Jiggler #{Jiggler::VERSION}")
184
+ exit(0)
185
+ end
186
+
187
+ o.on_tail '-h', '--help', 'Show help' do
188
+ puts o
189
+ exit(0)
190
+ end
191
+ end
192
+ parser.banner = 'Jiggler [options]'
193
+ parser
194
+ end
195
+
196
+ def setup_options(args)
197
+ opts = parse_options(args)
198
+
199
+ set_environment(opts)
200
+
201
+ opts = parse_config(opts[:config_file]).merge(opts) if opts[:config_file]
202
+ opts[:queues] = [Jiggler::Config::DEFAULT_QUEUE] if opts[:queues].nil?
203
+ opts[:server_mode] = true # cli starts only in server mode
204
+ config.merge!(opts)
205
+ end
206
+
207
+ def set_environment(opts)
208
+ opts[:environment] ||= ENV['APP_ENV'] || 'development'
209
+ @environment = opts[:environment]
210
+ end
211
+
212
+ def initialize_logger
213
+ @logger = config.logger
214
+ logger.level = ::Logger::DEBUG if config[:verbose]
215
+ end
216
+
217
+ def symbolize_keys_deep!(hash)
218
+ hash.keys.each do |k|
219
+ symkey = k.respond_to?(:to_sym) ? k.to_sym : k
220
+ hash[symkey] = hash.delete k
221
+ symbolize_keys_deep! hash[symkey] if hash[symkey].is_a? Hash
222
+ end
223
+ end
224
+
225
+ def parse_config(path)
226
+ erb = ERB.new(File.read(path))
227
+ erb.filename = File.expand_path(path)
228
+ opts = YAML.safe_load(erb.result, permitted_classes: [Symbol], aliases: true) || {}
229
+
230
+ symbolize_keys_deep!(opts)
231
+
232
+ opts = opts.merge(opts.delete(environment.to_sym) || {})
233
+ opts.delete(:strict)
234
+
235
+ opts
236
+ rescue => error
237
+ raise ArgumentError, "Error parsing config file: #{error.message}"
238
+ end
239
+
240
+ def ping_redis
241
+ config.with_sync_redis { |conn| conn.call('PING') }
242
+ true
243
+ rescue => err
244
+ logger.fatal("Redis connection error: #{err.message}")
245
+ false
246
+ end
247
+
248
+ def load_app
249
+ if config[:require].nil? || config[:require].empty?
250
+ logger.warn('No require option specified. Please specify a Ruby file to require with --require')
251
+ # allow to start empty server
252
+ return
253
+ end
254
+ # the code required by this file is expected to call Jiggler.configure
255
+ # thus it'll be executed in the context of the current process
256
+ # and apply the configuration for the server
257
+ require config[:require]
258
+ rescue LoadError => e
259
+ logger.fatal("Could not load jobs: #{e.message}")
260
+ exit(1)
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'logger'
5
+
6
+ module Jiggler
7
+ class Config
8
+ extend Forwardable
9
+
10
+ DEFAULT_QUEUE = 'default'
11
+ QUEUE_PREFIX = 'jiggler:list:'
12
+ SERVER_PREFIX = 'jiggler:svr:'
13
+ RETRIES_SET = 'jiggler:set:retries'
14
+ SCHEDULED_SET = 'jiggler:set:scheduled'
15
+ DEAD_SET = 'jiggler:set:dead'
16
+ PROCESSED_COUNTER = 'jiggler:stats:processed_counter'
17
+ FAILURES_COUNTER = 'jiggler:stats:failures_counter'
18
+
19
+ DEFAULTS = {
20
+ require: nil,
21
+ environment: 'development',
22
+ concurrency: 10,
23
+ timeout: 25,
24
+ max_dead_jobs: 10_000,
25
+ stats_interval: 10,
26
+ poller_enabled: true,
27
+ poll_interval: 5,
28
+ dead_timeout: 180 * 24 * 60 * 60, # 6 months in seconds
29
+ # client settings
30
+ client_concurrency: 10,
31
+ client_redis_pool: nil,
32
+ client_async: false,
33
+ }
34
+
35
+ def initialize(options = {})
36
+ @options = DEFAULTS.merge(options)
37
+ @options[:redis_url] = ENV['REDIS_URL'] if @options[:redis_url].nil? && ENV['REDIS_URL']
38
+ @options[:queues] ||= [DEFAULT_QUEUE]
39
+ @directory = {}
40
+ end
41
+
42
+ def queue_prefix
43
+ QUEUE_PREFIX
44
+ end
45
+
46
+ def retries_set
47
+ RETRIES_SET
48
+ end
49
+
50
+ def scheduled_set
51
+ SCHEDULED_SET
52
+ end
53
+
54
+ def dead_set
55
+ DEAD_SET
56
+ end
57
+
58
+ def default_queue
59
+ DEFAULT_QUEUE
60
+ end
61
+
62
+ # jiggler main process prefix
63
+ def server_prefix
64
+ SERVER_PREFIX
65
+ end
66
+
67
+ def processed_counter
68
+ PROCESSED_COUNTER
69
+ end
70
+
71
+ def failures_counter
72
+ FAILURES_COUNTER
73
+ end
74
+
75
+ def process_scan_key
76
+ @process_scan_key ||= "#{server_prefix}*"
77
+ end
78
+
79
+ def queue_scan_key
80
+ @queue_scan_key ||= "#{queue_prefix}*"
81
+ end
82
+
83
+ def prefixed_queues
84
+ @prefixed_queues ||= @options[:queues].map do |name|
85
+ "#{QUEUE_PREFIX}#{name}"
86
+ end
87
+ end
88
+
89
+ def with_async_redis
90
+ Async do
91
+ redis_pool.acquire do |conn|
92
+ yield conn
93
+ end
94
+ end
95
+ end
96
+
97
+ def with_sync_redis
98
+ Sync do
99
+ redis_pool.acquire do |conn|
100
+ yield conn
101
+ end
102
+ end
103
+ end
104
+
105
+ def redis_options
106
+ @redis_options ||= begin
107
+ opts = @options.slice(
108
+ :concurrency,
109
+ :redis_url
110
+ )
111
+
112
+ opts[:concurrency] += 2 # monitor + safety margin
113
+ opts[:concurrency] += 1 if @options[:poller_enabled]
114
+ opts[:async] = true
115
+
116
+ opts
117
+ end
118
+ end
119
+
120
+ def client_redis_options
121
+ @client_redis_options ||= begin
122
+ opts = @options.slice(
123
+ :redis_url,
124
+ :client_redis_pool
125
+ )
126
+
127
+ opts[:concurrency] = @options[:client_concurrency]
128
+ opts[:async] = @options[:client_async]
129
+ opts
130
+ end
131
+ end
132
+
133
+ def redis_pool
134
+ @redis_pool ||= Jiggler::RedisStore.new(redis_options).pool
135
+ end
136
+
137
+ def client_redis_pool
138
+ @client_redis_pool ||= begin
139
+ @options[:client_redis_pool] || Jiggler::RedisStore.new(client_redis_options).pool
140
+ end
141
+ end
142
+
143
+ def client_redis_pool=(new_pool)
144
+ @client_redis_pool = new_pool
145
+ end
146
+
147
+ def cleaner
148
+ @cleaner ||= Jiggler::Cleaner.new(self)
149
+ end
150
+
151
+ def summary
152
+ @summary ||= Jiggler::Summary.new(self)
153
+ end
154
+
155
+ def logger=(new_logger)
156
+ @logger = new_logger
157
+ end
158
+
159
+ def logger
160
+ @logger ||= ::Logger.new(STDOUT, level: :info)
161
+ end
162
+
163
+ def_delegators :@options, :[], :[]=, :fetch, :key?, :has_key?, :merge!, :delete, :slice
164
+ end
165
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+ require 'securerandom'
5
+
6
+ module Jiggler
7
+ def self.config
8
+ @config ||= Jiggler::Config.new
9
+ end
10
+
11
+ def self.logger
12
+ config.logger
13
+ end
14
+
15
+ def self.configure(&block)
16
+ block.call(config)
17
+ end
18
+
19
+ def self.summary
20
+ config.summary.all
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ class UnknownJobError < StandardError; end
5
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ module Job
5
+ module ClassMethods
6
+ def enqueue(*args)
7
+ Enqueuer.new(self).enqueue(*args)
8
+ end
9
+
10
+ def enqueue_in(seconds, *args)
11
+ Enqueuer.new(self).enqueue_in(seconds, *args)
12
+ end
13
+
14
+ # MyJob.with_options(queue: 'custom', retries: 3).enqueue(*args)
15
+ def with_options(options)
16
+ Enqueuer.new(self, options)
17
+ end
18
+
19
+ def enqueue_bulk(args_arr)
20
+ Enqueuer.new(self).enqueue_bulk(args_arr)
21
+ end
22
+
23
+ def queue
24
+ @queue || Jiggler::Config::DEFAULT_QUEUE
25
+ end
26
+
27
+ def retry_queue
28
+ @retry_queue || Jiggler::Config::DEFAULT_QUEUE
29
+ end
30
+
31
+ def retries
32
+ @retries || 0
33
+ end
34
+
35
+ def job_options(queue: nil, retries: nil, retry_queue: nil)
36
+ @queue = queue || Jiggler::Config::DEFAULT_QUEUE
37
+ @retries = retries || 0
38
+ @retry_queue = retry_queue || queue
39
+ end
40
+ end
41
+
42
+ class Enqueuer
43
+ def initialize(klass, options = {})
44
+ @options = options
45
+ @klass = klass
46
+ end
47
+
48
+ def with_options(options)
49
+ @options.merge(options)
50
+ self
51
+ end
52
+
53
+ def enqueue(*args)
54
+ Jiggler.config.client_redis_pool.acquire do |conn|
55
+ conn.call('LPUSH', list_name, job_args(args))
56
+ end
57
+ end
58
+
59
+ def enqueue_bulk(args_arr)
60
+ Jiggler.config.client_redis_pool.acquire do |conn|
61
+ conn.pipelined do |pipeline|
62
+ args_arr.each do |args|
63
+ pipeline.call('LPUSH', list_name, job_args(args))
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ def enqueue_in(seconds, *args)
70
+ timestamp = Time.now.to_f + seconds
71
+ Jiggler.config.client_redis_pool.acquire do |conn|
72
+ conn.call(
73
+ 'ZADD',
74
+ config.scheduled_set,
75
+ timestamp,
76
+ job_args(args)
77
+ )
78
+ end
79
+ end
80
+
81
+ def list_name
82
+ "#{config.queue_prefix}#{@options.fetch(:queue, @klass.queue)}"
83
+ end
84
+
85
+ def job_args(raw_args)
86
+ Oj.dump({ name: @klass.name, args: raw_args, **job_options }, mode: :compat)
87
+ end
88
+
89
+ def job_options
90
+ retries = @options.fetch(:retries, @klass.retries)
91
+ jid = @options.fetch(:jid, SecureRandom.hex(8))
92
+ { retries: retries, jid: jid }
93
+ end
94
+
95
+ def config
96
+ @config ||= Jiggler.config
97
+ end
98
+ end
99
+
100
+ def self.included(base)
101
+ base.extend(ClassMethods)
102
+ end
103
+
104
+ def enqueue(*args)
105
+ Enqueuer.new(self.class).enqueue(*args)
106
+ end
107
+
108
+ def enqueue_in(seconds, *args)
109
+ Enqueuer.new(self.class).enqueue_in(seconds, *args)
110
+ end
111
+
112
+ def perform(**args)
113
+ raise "#{self.class} must implement 'perform' method"
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ class Launcher
5
+ include Support::Helper
6
+
7
+ attr_reader :config
8
+
9
+ def initialize(config)
10
+ @done = false
11
+ @config = config
12
+ end
13
+
14
+ def start
15
+ poller.start if config[:poller_enabled]
16
+ monitor.start
17
+ manager.start
18
+ end
19
+
20
+ def suspend
21
+ return if @done
22
+
23
+ @done = true
24
+ manager.suspend
25
+
26
+ poller.terminate if config[:poller_enabled]
27
+ monitor.terminate
28
+ end
29
+
30
+ def stop
31
+ suspend
32
+ manager.terminate
33
+ end
34
+
35
+ private
36
+
37
+ def uuid
38
+ @uuid ||= begin
39
+ data_str = [
40
+ SecureRandom.hex(6),
41
+ config[:concurrency],
42
+ config[:timeout],
43
+ config[:queues].join(','),
44
+ config[:poller_enabled] ? '1' : '0',
45
+ Time.now.to_i,
46
+ Process.pid,
47
+ ENV['DYNO'] || Socket.gethostname
48
+ ].join(':')
49
+ "#{config.server_prefix}#{data_str}"
50
+ end
51
+ end
52
+
53
+ def collection
54
+ @collection ||= Stats::Collection.new(uuid)
55
+ end
56
+
57
+ def manager
58
+ @manager ||= Manager.new(config, collection)
59
+ end
60
+
61
+ def poller
62
+ @poller ||= Scheduled::Poller.new(config)
63
+ end
64
+
65
+ def monitor
66
+ @monitor ||= Stats::Monitor.new(config, collection)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ class Manager
5
+ include Support::Helper
6
+
7
+ def initialize(config, collection)
8
+ @workers = Set.new
9
+ @done = false
10
+ @config = config
11
+ @timeout = @config[:timeout]
12
+ @collection = collection
13
+ @config[:concurrency].times do
14
+ @workers << init_worker
15
+ end
16
+ end
17
+
18
+ def start
19
+ @workers.each(&:run)
20
+ end
21
+
22
+ def suspend
23
+ return if @done
24
+
25
+ @done = true
26
+ @workers.each(&:suspend)
27
+ end
28
+
29
+ def terminate
30
+ suspend
31
+ schedule_shutdown
32
+ wait_for_workers
33
+ end
34
+
35
+ private
36
+
37
+ def wait_for_workers
38
+ logger.info('Waiting for workers to finish...')
39
+ @workers.each(&:wait)
40
+ @shutdown_task.stop
41
+ end
42
+
43
+ def schedule_shutdown
44
+ @shutdown_task = Async do
45
+ sleep(@timeout)
46
+
47
+ next if @workers.empty?
48
+
49
+ hard_shutdown
50
+ end
51
+ end
52
+
53
+ def init_worker
54
+ Jiggler::Worker.new(
55
+ @config, @collection, &method(:process_worker_result)
56
+ )
57
+ end
58
+
59
+ def process_worker_result(worker, reason = nil)
60
+ @workers.delete(worker)
61
+ unless @done
62
+ new_worker = init_worker
63
+ @workers << new_worker
64
+ new_worker.run
65
+ end
66
+ end
67
+
68
+ def hard_shutdown
69
+ logger.warn('Hard shutdown, terminating workers...')
70
+ @workers.each(&:terminate)
71
+ end
72
+ end
73
+ end