process_balancer 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9e2b7936e66ec20f0b2cf36e107dd9a2aae52bdd19e03617f44d6e36abd5c008
4
+ data.tar.gz: 5c7b264651a91513a59545e2fdcbbfca0bd13d079c4787e8fce3c90370e3d59c
5
+ SHA512:
6
+ metadata.gz: 1c846bde2ec1126116b529471e338e3caec0bd87695f38e6fd61bbfca5e9bdf29c888dbb55b99a7b849dc3584c1807d95ddd198f05eec6fb0ba837d24c5a313d
7
+ data.tar.gz: 3aab7f4a78e10282065c1dfba9c7d39ea74e489d52de600c85f42e6608aab07864380aa36df49a8f13c2b3e4929230d010ed782561614648f98f81696ebd72d1
File without changes
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ require 'pp' # workaround for FakeFS
6
+
7
+ # Specify your gem's dependencies in process_balancer.gemspec
8
+ gemspec
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Edward Rudd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,90 @@
1
+ = ProcessBalancer
2
+
3
+ ProcessBalancer is a background job runner that is targeted toward the specific use-case of long running jobs.
4
+
5
+ If you need a job runner that runs small background jobs, look to https://sidekiq.org/[Sidekiq].
6
+
7
+ ProcessBalancer has built-in functionality to balance your jobs across multiple instances.
8
+
9
+ == Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ [source,ruby]
14
+ ----
15
+ gem 'process_balancer'
16
+ ----
17
+
18
+ And then execute:
19
+
20
+ $ bundle
21
+
22
+ Or install it yourself as:
23
+
24
+ $ gem install process_balancer
25
+
26
+ == Usage
27
+
28
+ Build a Job class that works through an iteration of your processing.
29
+ The iteration does not have to be one record, it can be working through 1000 records.
30
+ The iteration needs to be designed to lock its work atomically so that multiple concurrent workers could be running.
31
+ The ProcessBalancer takes care of scaling out to run however many workers you want running across however many nodes you have running.
32
+ Each instance of the Job will have a unique worker_id to ensure they do not trample on each other.
33
+
34
+ [source,ruby]
35
+ ----
36
+ class ProcessQueue < ProcessBalancer::Base
37
+ # set a worker locking algorithm
38
+ lock_driver :simple_redis
39
+
40
+ LOCK_SQL = <<~SQL
41
+ WITH T as (
42
+ SELECT ctid
43
+ FROM queue_table
44
+ WHERE status = #{QueueRecord::QUEUED} AND lock IS NULL
45
+ ORDER BY id
46
+ LIMIT 1000
47
+ FOR UPDATE SKIP LOCKED
48
+ )
49
+ UPDATE queue_records
50
+ SET lock = :lock, updated_at = :now
51
+ WHERE ctid = ANY(ARRAY(SELECT ctid FROM T))
52
+ SQL
53
+
54
+ def lock_records
55
+ # grab a # of records and lock them with the worker_id
56
+ sql = ActiveRecord::Base.sanitize_sql([LOCK_SQL, {lock: worker_index, now: Time.now}])
57
+ ActiveRecord::Base.connection.execute(sql)
58
+ # process those records
59
+ QueueRecord.where(lock: worker_index)
60
+ end
61
+
62
+ def process_record(entry)
63
+ # do processing
64
+ # mark record as processed and release the lock on that record
65
+ entry.update(lock: nil, status: QueueRecord::PROCESSED)
66
+ end
67
+
68
+ def unlock_records
69
+ # if any error occurs unlock any of our unprocessed records
70
+ QueueRecord.where(lock: worker_index).update_all(lock: nil)
71
+ end
72
+ end
73
+ ----
74
+
75
+ Configuration file
76
+
77
+ [source,yaml]
78
+ ----
79
+ jobs:
80
+ process_queue:
81
+ class: 'ProcessQueue'
82
+ ----
83
+
84
+ == Contributing
85
+
86
+ Bug reports and pull requests are welcome on GitHub at https://github.com/NetsoftHoldings/process_balancer.
87
+
88
+ == License
89
+
90
+ The gem is available as open source under the terms of the https://opensource.org/licenses/LGPL-3.0[LGPLv3 License].
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1 @@
1
+ - [ ] add in simple error handling
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/process_balancer/cli'
5
+
6
+ begin
7
+ cli = ProcessBalancer::CLI.instance
8
+ cli.parse
9
+ cli.run
10
+ rescue StandardError => e
11
+ raise e if $DEBUG
12
+
13
+ warn e.message
14
+ warn e.backtrace.join("\n")
15
+
16
+ exit 1
17
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'socket'
5
+ require 'securerandom'
6
+
7
+ require_relative 'process_balancer/version'
8
+ require_relative 'process_balancer/redis_connection'
9
+ require_relative 'process_balancer/base'
10
+
11
+ module ProcessBalancer # :nodoc:
12
+ class Error < StandardError; end
13
+
14
+ PROCESSES_KEY = 'processes'
15
+ WORKER_COUNT_KEY = 'worker_counts'
16
+
17
+ DEFAULTS = {
18
+ redis: {},
19
+ job_sets: [],
20
+ require: '.',
21
+ max_threads: 10,
22
+ shutdown_timeout: 30,
23
+ reloader: proc { |&block| block.call },
24
+ }.freeze
25
+
26
+ def self.options
27
+ @options ||= DEFAULTS.dup
28
+ end
29
+
30
+ def self.options=(opts)
31
+ @options = opts
32
+ end
33
+
34
+ ##
35
+ # Configuration for ProcessBalancer, use like:
36
+ #
37
+ # ProcessBalancer.configure do |config|
38
+ # config.redis = { :namespace => 'myapp', :size => 25, :url => 'redis://myhost:8877/0' }
39
+ # if config.server?
40
+ # # any configuration specific to server
41
+ # end
42
+ # end
43
+ def self.configure
44
+ yield self
45
+ end
46
+
47
+ def self.server?
48
+ defined?(ProcessBalancer::CLI)
49
+ end
50
+
51
+ def self.logger
52
+ @logger ||= Logger.new(STDOUT, level: Logger::INFO)
53
+ end
54
+
55
+ def self.redis
56
+ raise ArgumentError, 'requires a block' unless block_given?
57
+
58
+ redis_pool.with do |conn|
59
+ retryable = true
60
+ begin
61
+ yield conn
62
+ rescue Redis::CommandError => e
63
+ # if we are on a slave, disconnect and reopen to get back on the master
64
+ (conn.disconnect!; retryable = false; retry) if retryable && e.message =~ /READONLY/
65
+ raise
66
+ end
67
+ end
68
+ end
69
+
70
+ def self.redis_pool
71
+ @redis_pool ||= RedisConnection.create(options[:redis])
72
+ end
73
+
74
+ def self.redis=(hash)
75
+ @redis_pool = if hash.is_a?(ConnectionPool)
76
+ hash
77
+ else
78
+ RedisConnection.create(hash)
79
+ end
80
+ end
81
+
82
+ def self.reset
83
+ @redis_pool = nil
84
+ @options = nil
85
+ @logger = nil
86
+ @process_nonce = nil
87
+ @identity = nil
88
+ end
89
+
90
+ def self.hostname
91
+ ENV['DYNO'] || Socket.gethostname
92
+ end
93
+
94
+ def self.process_nonce
95
+ @process_nonce ||= SecureRandom.hex(6)
96
+ end
97
+
98
+ def self.identity
99
+ @identity ||= "#{hostname}:#{$PID}:#{process_nonce}"
100
+ end
101
+
102
+ def self.adjust_scheduled_workers(job_id, by: nil, to: nil)
103
+ if !to.nil?
104
+ redis { |c| c.hset(WORKER_COUNT_KEY, job_id.to_s, to) }
105
+ elsif !by.nil?
106
+ redis { |c| c.hincrby(WORKER_COUNT_KEY, job_id.to_s, by) }
107
+ else
108
+ raise ArgumentError, 'Must specify either by: (an increment/decrement) or to: (an exact value)'
109
+ end
110
+ end
111
+
112
+ def self.scheduled_workers(job_id)
113
+ value = redis { |c| c.hget(WORKER_COUNT_KEY, job_id.to_s) }&.to_i
114
+ value.nil? ? 1 : value
115
+ end
116
+
117
+ def self.running_workers(job_id)
118
+ count = 0
119
+
120
+ redis do |c|
121
+ workers = c.lrange(PROCESSES_KEY, 0, -1)
122
+
123
+ workers.each do |worker|
124
+ data = c.hget("#{worker}:workers", job_id)
125
+ if data
126
+ data = JSON.parse(data, symbolize_names: true)
127
+ count += (data.dig(:running)&.size || 0)
128
+ end
129
+ rescue JSON::ParserError
130
+ nil
131
+ end
132
+ end
133
+
134
+ count
135
+ end
136
+ end
137
+
138
+ require 'process_balancer/rails' if defined?(::Rails::Engine)
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessBalancer
4
+ class Base # :nodoc:
5
+ attr_reader :worker_index, :status, :options
6
+
7
+ def self.lock_driver(driver)
8
+ if driver.is_a?(Symbol)
9
+ file = "process_balancer/lock/#{driver}"
10
+ driver = driver.to_s
11
+ unless driver !~ /_/ && driver =~ /[A-Z]+.*/
12
+ driver = driver.split('_').map(&:capitalize).join
13
+ end
14
+ require file
15
+ klass = ProcessBalancer::Lock.const_get(driver)
16
+ include klass
17
+ else
18
+ raise ArgumentError, 'Please pass a symbol for the driver to use'
19
+ end
20
+ end
21
+
22
+ def initialize(worker_index, options = {})
23
+ @worker_index = worker_index
24
+ @options = options
25
+ end
26
+
27
+ def perform
28
+ before_perform
29
+ worker_lock do |lock|
30
+ @status = nil
31
+ records = lock_records
32
+ lock.extend!
33
+ records&.each do |r|
34
+ process_record(r)
35
+ lock.extend!
36
+ end
37
+ @status
38
+ ensure
39
+ unlock_records
40
+ after_perform
41
+ end
42
+ end
43
+
44
+ def status_abort
45
+ @status = :abort
46
+ end
47
+
48
+ def status_sleep(duration)
49
+ @status = [:sleep, duration]
50
+ end
51
+
52
+ def runtime_lock_timeout
53
+ options[:runtime_lock_timeout] || 30
54
+ end
55
+
56
+ def job_id
57
+ options[:id]
58
+ end
59
+
60
+ def before_perform; end
61
+
62
+ def after_perform; end
63
+
64
+ def lock_records
65
+ raise NotImplementedError
66
+ end
67
+
68
+ def unlock_records
69
+ raise NotImplementedError
70
+ end
71
+
72
+ def process_record(record)
73
+ raise NotImplementedError
74
+ end
75
+
76
+ def worker_lock(&_block)
77
+ raise NotImplementedError, 'Specify a locking driver via lock_driver :driver'
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ $stdout.sync = true
4
+
5
+ require 'optparse'
6
+ require 'yaml'
7
+ require 'erb'
8
+
9
+ require_relative '../process_balancer'
10
+ require_relative 'manager'
11
+ require_relative 'util'
12
+
13
+ module ProcessBalancer
14
+ class CLI # :nodoc:
15
+ include Util
16
+
17
+ attr_reader :manager, :environment
18
+
19
+ def self.instance
20
+ @instance ||= new
21
+ end
22
+
23
+ def parse(args = ARGV)
24
+ setup_options(args)
25
+ initialize_logger
26
+ validate!
27
+ end
28
+
29
+ def run
30
+ boot_system
31
+ logger.info "Booted Rails #{::Rails.version} application in #{environment} environment" if rails_app?
32
+ Thread.current.name = 'main'
33
+
34
+ self_read, self_write = IO.pipe
35
+ signals = %w[INT TERM TTIN TSTP USR1 USR2]
36
+
37
+ signals.each do |sig|
38
+ trap sig do
39
+ self_write.write("#{sig}\n")
40
+ end
41
+ rescue ArgumentError
42
+ logger.info "Signal #{sig} not supported"
43
+ end
44
+
45
+ logger.info "Running in #{RUBY_DESCRIPTION}"
46
+
47
+ if options[:job_sets].empty?
48
+ logger.error 'No jobs configured! Configure your jobs in the configuration file.'
49
+ else
50
+ logger.info 'Configured jobs'
51
+ options[:job_sets].each do |config|
52
+ logger.info " - #{config[:id]}"
53
+ end
54
+ end
55
+
56
+ @manager = ProcessBalancer::Manager.new(options)
57
+
58
+ begin
59
+ @manager.run
60
+
61
+ while (readable_io = IO.select([self_read]))
62
+ signal = readable_io.first[0].gets.strip
63
+ handle_signal(signal)
64
+ end
65
+ rescue Interrupt
66
+ logger.info 'Shutting down'
67
+ @manager.stop
68
+ logger.info 'Bye!'
69
+
70
+ # Explicitly exit so busy Processor threads wont block process shutdown.
71
+ exit(0)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # region initializing app
78
+ def boot_system
79
+ ENV['RACK_ENV'] = ENV['RAILS_ENV'] = environment
80
+ if File.directory?(options[:require])
81
+ require 'rails'
82
+ if ::Rails::VERSION::MAJOR < 5
83
+ raise 'Only rails 5+ is supported'
84
+ else
85
+ require 'process_balancer/rails'
86
+ require File.expand_path("#{options[:require]}/config/environment.rb")
87
+ end
88
+ else
89
+ require options[:require]
90
+ end
91
+ end
92
+
93
+ # endregion
94
+
95
+ # region option and configuration handling
96
+ def parse_options(argv)
97
+ opts = {redis: {}}
98
+
99
+ @parser = OptionParser.new do |o|
100
+ o.on('-eENVIRONMENT', '--environment ENVIRONMENT', 'Specify the app environment') do |arg|
101
+ opts[:environment] = arg
102
+ end
103
+
104
+ o.on('-rREQUIRE', '--require REQUIRE', 'Specify rails app path or file to boot your app with jobs') do |arg|
105
+ opts[:require] = arg
106
+ end
107
+
108
+ o.on('-cFILE', '--config FILE', 'Specify a configuration file. Default is config/process_balancer.yml in the rails app') do |arg|
109
+ opts[:config_file] = arg
110
+ end
111
+
112
+ o.on('-v', '--[no-]verbose', 'Run verbosely') do |v|
113
+ opts[:verbose] = v
114
+ end
115
+ end
116
+
117
+ @parser.banner = 'process_balancer [options]'
118
+ @parser.on_tail '-h', '--help', 'Show help' do
119
+ logger.info parser.help
120
+ exit(1)
121
+ end
122
+
123
+ @parser.parse!(argv)
124
+
125
+ opts
126
+ end
127
+
128
+ def locate_config_file!(opts)
129
+ if opts[:config_file]
130
+ unless File.exist?(opts[:config_file])
131
+ raise ArgumentError, "Config file not found: #{opts[:config_file]}"
132
+ end
133
+ else
134
+ config_dir = if opts[:require] && File.directory?(opts[:require])
135
+ File.join(opts[:require], 'config')
136
+ else
137
+ File.join(options[:require], 'config')
138
+ end
139
+
140
+ %w[process_balancer.yml process_balancer.yml.erb].each do |config_file|
141
+ path = File.join(config_dir, config_file)
142
+ if File.exist?(path)
143
+ opts[:config_file] ||= path
144
+ end
145
+ end
146
+ end
147
+ end
148
+
149
+ def parse_config(path)
150
+ config = YAML.safe_load(ERB.new(File.read(path)).result, symbolize_names: true) || {}
151
+
152
+ opts = {}
153
+ # pull in global config
154
+ opts.merge!(config.dig(:global) || {})
155
+ # pull in ENV override config
156
+ opts.merge!(config.dig(:environments, environment.to_sym) || {})
157
+
158
+ opts[:job_sets] = parse_jobs(config)
159
+
160
+ opts
161
+ end
162
+
163
+ def parse_jobs(config)
164
+ (config[:jobs] || {}).map do |id, job|
165
+ {
166
+ id: id,
167
+ **job,
168
+ }
169
+ end
170
+ end
171
+
172
+ def setup_options(args)
173
+ opts = parse_options(args)
174
+
175
+ setup_environment opts[:environment]
176
+
177
+ locate_config_file!(opts)
178
+
179
+ opts = parse_config(opts[:config_file]).merge(opts) if opts[:config_file]
180
+
181
+ options.merge!(opts)
182
+ end
183
+
184
+ def validate!
185
+ if !File.exist?(options[:require]) ||
186
+ (File.directory?(options[:require]) && !File.exist?("#{options[:require]}/config/application.rb"))
187
+ logger.info 'Please point process balancer to a Rails application or a Ruby file'
188
+ logger.info 'to load your job classes with -r [DIR|FILE].'
189
+ logger.info @parser.help
190
+ exit(1)
191
+ end
192
+ end
193
+
194
+ def options
195
+ ProcessBalancer.options
196
+ end
197
+
198
+ def initialize_logger
199
+ logger.level = ::Logger::DEBUG if options[:verbose]
200
+ end
201
+
202
+ # endregion
203
+
204
+ # region signal handling
205
+ SIGNAL_HANDLERS = {
206
+ INT: lambda { |_cli|
207
+ # Ctrl-C in terminal
208
+ raise Interrupt
209
+ },
210
+ TERM: lambda { |_cli|
211
+ # TERM is the signal that process must exit.
212
+ # Heroku sends TERM and then waits 30 seconds for process to exit.
213
+ raise Interrupt
214
+ },
215
+ USR1: lambda { |cli|
216
+ ProcessBalancer.logger.info 'Received USR1, no longer accepting new work'
217
+ cli.manager.quiet
218
+ },
219
+ TSTP: lambda { |cli|
220
+ ProcessBalancer.logger.info 'Received TSTP, no longer accepting new work'
221
+ cli.manager.quiet
222
+ },
223
+ TTIN: lambda { |_cli|
224
+ Thread.list.each do |thread|
225
+ ProcessBalancer.logger.warn "Thread TID-#{(thread.object_id ^ ::Process.pid).to_s(36)} #{thread.name}"
226
+ if thread.backtrace
227
+ ProcessBalancer.logger.warn thread.backtrace.join("\n")
228
+ else
229
+ ProcessBalancer.logger.warn '<no backtrace available>'
230
+ end
231
+ end
232
+ },
233
+ }.freeze
234
+
235
+ def handle_signal(sig)
236
+ logger.debug "Got #{sig} signal"
237
+ handle = SIGNAL_HANDLERS[sig.to_sym]
238
+ if handle
239
+ handle.call(self)
240
+ else
241
+ logger.info("No signal handler for #{sig}")
242
+ end
243
+ end
244
+
245
+ # endregion
246
+
247
+ # region environment
248
+ def setup_environment(cli_env)
249
+ @environment = cli_env || ENV['APP_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
250
+ end
251
+
252
+ def rails_app?
253
+ defined?(::Rails) && ::Rails.respond_to?(:application)
254
+ end
255
+ # endregion
256
+ end
257
+ end