skiplock 1.0.20 → 1.0.24

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 982ed70ce969943c421cad0923a280c87c4c26ea90da5a3451ce73becd5db42e
4
- data.tar.gz: 9f68becb9ab21e00816315d1e2ff20058d2f5942a3688ed78959018e3ccf940f
3
+ metadata.gz: a43b01cedd94ea395b0b173e2da3c5c7ab0baf6ccbd35e599127544084ca5589
4
+ data.tar.gz: a908c4eea5b2b75727a70fe5efc248f0b0183e9fe97a37c068b185b9f259a4ba
5
5
  SHA512:
6
- metadata.gz: 0fb583418cf8c0a190f9b651ecd95effba2d4b415f91bf6fbd452d2271f02534e7c5823e360f1e7a258cdd0a22b14cc99d44915007e9c32a186371748c086144
7
- data.tar.gz: 6aa546ad0a538213ae9587e667543027fb2ca080e7623b3f409fe6303c005085f39fd27c310a690a2da9fbb50321d97519ed9aee7bb4922a126c0a280f6ad5fe
6
+ metadata.gz: c5ae8b9bd79e37426710d707fadd2e4d658e541ffdd109a760687f2c14dacfa9d61614130d25be801264ecee86ea63d54ce4d6604c4cc80c7a47ac31752618f6
7
+ data.tar.gz: 42ba6d9047ac028c24389d1915f686f5d553751244bd557a98a8b28af311a53702d6c69078abafcb6696d5d00e441fd8eca43d2e18b11530945fa10a42d15cea
data/README.md CHANGED
@@ -50,12 +50,14 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
50
50
  ```yaml
51
51
  # config/skiplock.yml (default settings)
52
52
  ---
53
+ graceful_shutdown: 15
53
54
  min_threads: 1
54
55
  max_threads: 10
55
56
  max_retries: 20
56
57
  logfile: skiplock.log
57
58
  loglevel: info
58
59
  notification: custom
60
+ actioncable: false
59
61
  extensions: false
60
62
  purge_completion: true
61
63
  queues:
@@ -64,12 +66,14 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
64
66
  workers: 0
65
67
  ```
66
68
  Available configuration options are:
69
+ - **graceful_shutdown** (*integer*): sets the number of seconds to wait for jobs to finish before being killed during shutdown
67
70
  - **min_threads** (*integer*): sets minimum number of threads staying idle
68
71
  - **max_threads** (*integer*): sets the maximum number of threads allowed to run jobs
69
72
  - **max_retries** (*integer*): sets the maximum attempt a job will be retrying before it is marked expired. See `Retry system` for more details
70
73
  - **logfile** (*string*): filename for skiplock logs; empty logfile will disable logging
71
74
  - **loglevel** (*string*): sets logging level (`debug, info, warn, error, fatal, unknown`)
72
75
  - **notification** (*string*): sets the library to be used for notifying errors and exceptions (`auto, airbrake, bugsnag, exception_notification, custom`); using `auto` will detect library if available. See `Notification system` for more details
76
+ - **actioncable** (*boolean*): enable or disable usage of ActionCable notification
73
77
  - **extensions** (*multi*): enable or disable the class method extension. See `ClassMethod extension` for more details
74
78
  - **purge_completion** (*boolean*): when set to **true** will delete jobs after they were completed successfully; if set to **false** then the completed jobs should be purged periodically to maximize performance (eg. clean up old jobs after 3 months); queued jobs can manually override using `purge` option
75
79
  - **queues** (*hash*): defines the set of queues with priorities; lower priority takes precedence
@@ -83,8 +87,10 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
83
87
  ```
84
88
  $ bundle exec skiplock -h
85
89
  Usage: skiplock [options]
90
+ -a, --actioncable YESNO Actioncable notification
86
91
  -e, --environment STRING Rails environment
87
92
  -l, --logfile STRING Log filename
93
+ -L, --loglevel STRING Log level (debug, info, warn, error, fatal, unknown)
88
94
  -s, --graceful-shutdown NUM Number of seconds to wait for graceful shutdown
89
95
  -r, --max-retries NUM Number of maxixum retries
90
96
  -t, --max-threads NUM Number of maximum threads
@@ -172,14 +178,10 @@ If the `retry_on` block is not defined, then the built-in retry system of `Skipl
172
178
  `Skiplock` can use existing exception notification library to notify errors and exceptions. It supports `airbrake`, `bugsnag`, and `exception_notification`. Custom notification can also be called whenever an exception occurs; it can be configured in an initializer like below:
173
179
  ```ruby
174
180
  # config/initializers/skiplock.rb
175
- Skiplock.on_error do |ex, previous|
176
- if ex.backtrace != previous.try(:backtrace)
177
- # sends text message using Amazon SNS on new exceptions only
178
- # the same repeated exceptions will only be sent once to avoid SPAM
179
- # NOTE: exceptions generated from Job executions will not provide 'previous' exceptions
180
- sms = Aws::SNS::Client.new(region: 'us-west-2', access_key_id: Rails.application.credentials[:aws][:access_key_id], secret_access_key: Rails.application.credentials[:aws][:secret_access_key])
181
- sms.publish({ phone_number: '+122233334444', message: "Exception: #{ex.message}"[0..130] })
182
- end
181
+ Skiplock.on_error do |ex|
182
+ # sends text message using Amazon SNS
183
+ sms = Aws::SNS::Client.new(region: 'us-west-2', access_key_id: Rails.application.credentials[:aws][:access_key_id], secret_access_key: Rails.application.credentials[:aws][:secret_access_key])
184
+ sms.publish(phone_number: '+12223334444', message: "Exception: #{ex.message}"[0..130])
183
185
  end
184
186
  # supports multiple 'on_error' event callbacks
185
187
  ```
@@ -241,6 +243,10 @@ Code examples of gathering counters information:
241
243
  ```ruby
242
244
  Skiplock::Counter.sum(:expiries)
243
245
  ```
246
+ - get all information in one query
247
+ ```ruby
248
+ Skiplock::Counter.pluck("sum(completions), sum(dispatches), sum(expiries), sum(failures), sum(retries)").first
249
+ ```
244
250
 
245
251
  ## Contributing
246
252
 
data/bin/skiplock CHANGED
@@ -5,6 +5,7 @@ options = {}
5
5
  begin
6
6
  op = OptionParser.new do |opts|
7
7
  opts.banner = "Usage: #{File.basename($0)} [options]"
8
+ opts.on('-a', '--actioncable YESNO', TrueClass, 'Actioncable notification')
8
9
  opts.on('-e', '--environment STRING', String, 'Rails environment')
9
10
  opts.on('-l', '--logfile STRING', String, 'Log filename')
10
11
  opts.on('-L', '--loglevel STRING', String, 'Log level (debug, info, warn, error, fatal, unknown)')
data/lib/skiplock/cron.rb CHANGED
@@ -19,6 +19,7 @@ module Skiplock
19
19
  query = Job.where('cron IS NOT NULL')
20
20
  query = query.where('job_class NOT IN (?)', cronjobs) if cronjobs.count > 0
21
21
  query.delete_all
22
+ rescue
22
23
  end
23
24
 
24
25
  def self.next_schedule_at(cron)
data/lib/skiplock/job.rb CHANGED
@@ -1,7 +1,10 @@
1
1
  module Skiplock
2
2
  class Job < ActiveRecord::Base
3
3
  self.implicit_order_column = 'updated_at'
4
- attr_accessor :activejob_error
4
+ attribute :activejob_error
5
+ attribute :exception
6
+ attribute :max_retries, :integer
7
+ attribute :purge, :boolean
5
8
  belongs_to :worker, inverse_of: :jobs, required: false
6
9
 
7
10
  def self.dispatch(purge_completion: true, max_retries: 20)
@@ -40,7 +43,7 @@ module Skiplock
40
43
  Dir.glob('tmp/skiplock/*').each do |f|
41
44
  disposed = true
42
45
  if self.exists?(id: File.basename(f), running: true)
43
- job = Marshal.load(File.binread(f)) rescue nil
46
+ job = YAML.load_file(f) rescue nil
44
47
  disposed = job.dispose if job.is_a?(Skiplock::Job)
45
48
  end
46
49
  (File.delete(f) rescue nil) if disposed
@@ -54,15 +57,15 @@ module Skiplock
54
57
  end
55
58
 
56
59
  def dispose
57
- return unless @max_retries
58
- dump = Marshal.dump(self)
60
+ return unless self.max_retries
61
+ yaml = self.to_yaml
59
62
  purging = false
60
63
  self.running = false
61
64
  self.worker_id = nil
62
65
  self.updated_at = Time.now > self.updated_at ? Time.now : self.updated_at + 1 # in case of clock drifting
63
- if @exception
64
- self.exception_executions["[#{@exception.class.name}]"] = self.exception_executions["[#{@exception.class.name}]"].to_i + 1 unless self.data.key?('activejob_error')
65
- if (self.executions.to_i >= @max_retries + 1) || self.data.key?('activejob_error') || @exception.is_a?(Skiplock::Extension::ProxyError)
66
+ if self.exception
67
+ self.exception_executions["[#{self.exception.class.name}]"] = self.exception_executions["[#{self.exception.class.name}]"].to_i + 1 unless self.data.key?('activejob_error')
68
+ if (self.executions.to_i >= self.max_retries + 1) || self.data.key?('activejob_error') || self.exception.is_a?(Skiplock::Extension::ProxyError)
66
69
  self.expired_at = Time.now
67
70
  else
68
71
  self.scheduled_at = Time.now + (5 * 2**self.executions.to_i)
@@ -76,7 +79,7 @@ module Skiplock
76
79
  next_cron_at = Cron.next_schedule_at(self.cron)
77
80
  if next_cron_at
78
81
  # update job to record completions counter before resetting finished_at to nil
79
- self.update_columns(self.attributes.slice(*self.changes.keys))
82
+ self.update_columns(self.attributes.slice(*(self.changes.keys & self.class.column_names)))
80
83
  self.finished_at = nil
81
84
  self.executions = nil
82
85
  self.exception_executions = nil
@@ -86,13 +89,13 @@ module Skiplock
86
89
  Skiplock.logger.error("[Skiplock] ERROR: Invalid CRON '#{self.cron}' for Job #{self.job_class}") if Skiplock.logger
87
90
  purging = true
88
91
  end
89
- elsif @purge == true
92
+ elsif self.purge == true
90
93
  purging = true
91
94
  end
92
95
  end
93
- purging ? self.delete : self.update_columns(self.attributes.slice(*self.changes.keys))
96
+ purging ? self.delete : self.update_columns(self.attributes.slice(*(self.changes.keys & self.class.column_names)))
94
97
  rescue Exception => e
95
- File.binwrite("tmp/skiplock/#{self.id}", dump) rescue nil
98
+ File.write("tmp/skiplock/#{self.id}", yaml) rescue nil
96
99
  if Skiplock.logger
97
100
  Skiplock.logger.error(e.to_s)
98
101
  Skiplock.logger.error(e.backtrace.join("\n"))
@@ -109,9 +112,9 @@ module Skiplock
109
112
  self.data.delete('result')
110
113
  self.exception_executions ||= {}
111
114
  self.activejob_error = nil
112
- @max_retries = (self.data['options'].key?('max_retries') ? self.data['options']['max_retries'].to_i : max_retries) rescue max_retries
113
- @max_retries = 20 if @max_retries < 0 || @max_retries > 20
114
- @purge = (self.data['options'].key?('purge') ? self.data['options']['purge'] : purge_completion) rescue purge_completion
115
+ self.max_retries = (self.data['options'].key?('max_retries') ? self.data['options']['max_retries'].to_i : max_retries) rescue max_retries
116
+ self.max_retries = 20 if self.max_retries < 0 || self.max_retries > 20
117
+ self.purge = (self.data['options'].key?('purge') ? self.data['options']['purge'] : purge_completion) rescue purge_completion
115
118
  job_data = self.attributes.slice('job_class', 'queue_name', 'locale', 'timezone', 'priority', 'executions', 'exception_executions').merge('job_id' => self.id, 'enqueued_at' => self.updated_at, 'arguments' => (self.data['arguments'] || []))
116
119
  self.executions = self.executions.to_i + 1
117
120
  Thread.current[:skiplock_job] = self
@@ -119,15 +122,15 @@ module Skiplock
119
122
  begin
120
123
  self.data['result'] = ActiveJob::Base.execute(job_data)
121
124
  rescue Exception => ex
122
- @exception = ex
123
- Skiplock.on_errors.each { |p| p.call(@exception) }
125
+ self.exception = ex
126
+ Skiplock.on_errors.each { |p| p.call(ex) }
124
127
  end
125
128
  if Skiplock.logger
126
- if @exception || self.activejob_error
129
+ if self.exception || self.activejob_error
127
130
  Skiplock.logger.error("[Skiplock] Job #{self.job_class} (#{self.id}) was interrupted by an exception#{ ' (rescued and retried by ActiveJob)' if self.activejob_error }")
128
- if @exception
129
- Skiplock.logger.error(@exception.to_s)
130
- Skiplock.logger.error(@exception.backtrace.join("\n"))
131
+ if self.exception
132
+ Skiplock.logger.error(self.exception.to_s)
133
+ Skiplock.logger.error(self.exception.backtrace.join("\n"))
131
134
  end
132
135
  else
133
136
  end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -139,7 +142,7 @@ module Skiplock
139
142
  Skiplock.logger.info "[Skiplock] Performed #{job_name} (#{self.id}) from queue '#{self.queue_name || 'default'}' in #{end_time - start_time} seconds"
140
143
  end
141
144
  end
142
- @exception || self.activejob_error || self.data['result']
145
+ self.exception || self.activejob_error || self.data['result']
143
146
  ensure
144
147
  self.finished_at ||= Time.now if self.data.key?('result') && !self.activejob_error
145
148
  self.dispose
@@ -4,14 +4,22 @@ module Skiplock
4
4
  @config = Skiplock::DEFAULT_CONFIG.dup
5
5
  @config.merge!(YAML.load_file('config/skiplock.yml')) rescue nil
6
6
  @config.symbolize_keys!
7
- async if (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0)
7
+ Rails.application.eager_load! if Rails.env.development?
8
+ if @config[:extensions] == true
9
+ Module.__send__(:include, Skiplock::Extension)
10
+ elsif @config[:extensions].is_a?(Array)
11
+ @config[:extensions].each { |n| n.constantize.__send__(:extend, Skiplock::Extension) if n.safe_constantize }
12
+ end
13
+ ActiveJob::Base.__send__(:include, Skiplock::Patch)
14
+ (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0) ? async : Cron.setup
8
15
  end
9
16
 
10
17
  def async
11
- configure
12
18
  setup_logger
19
+ configure
13
20
  Worker.cleanup(@hostname)
14
- @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
21
+ @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname, actioncable: @config[:actioncable])
22
+ Cron.setup if @worker.master
15
23
  @worker.start(**@config)
16
24
  at_exit { @worker.shutdown }
17
25
  rescue Exception => ex
@@ -21,25 +29,24 @@ module Skiplock
21
29
 
22
30
  def standalone(**options)
23
31
  @config.merge!(options)
24
- configure
25
- setup_logger
26
- Rails.logger.reopen('/dev/null') rescue Rails.logger.reopen('NUL') # supports Windows NUL device
27
- Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
28
- @config[:workers] = 1 if @config[:workers] <= 0
29
32
  @config[:standalone] = true
33
+ @config[:workers] = 1 if @config[:workers] <= 0
34
+ setup_logger
35
+ configure
30
36
  banner
31
- Worker.cleanup(@hostname)
32
- @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
33
37
  @parent_id = Process.pid
34
38
  @shutdown = false
35
39
  Signal.trap('INT') { @shutdown = true }
36
40
  Signal.trap('TERM') { @shutdown = true }
37
41
  Signal.trap('HUP') { setup_logger }
42
+ Worker.cleanup(@hostname)
43
+ @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname, actioncable: @config[:actioncable])
44
+ ActiveRecord::Base.connection.disconnect! if @config[:workers] > 1
38
45
  (@config[:workers] - 1).times do |n|
39
- sleep 0.2
40
46
  fork do
41
- sleep 1
42
- worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname, master: false)
47
+ sleep(0.25*n + 1)
48
+ ActiveRecord::Base.establish_connection
49
+ worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname, master: false, actioncable: @config[:actioncable])
43
50
  worker.start(worker_num: n + 1, **@config)
44
51
  loop do
45
52
  sleep 0.5
@@ -48,6 +55,7 @@ module Skiplock
48
55
  worker.shutdown
49
56
  end
50
57
  end
58
+ ActiveRecord::Base.establish_connection if @config[:workers] > 1
51
59
  @worker.start(**@config)
52
60
  loop do
53
61
  sleep 0.5
@@ -68,24 +76,25 @@ module Skiplock
68
76
  @logger.info "-"*(title.length)
69
77
  @logger.info title
70
78
  @logger.info "-"*(title.length)
71
- @logger.info "ClassMethod extensions: #{@config[:extensions]}"
72
- @logger.info " Purge completion: #{@config[:purge_completion]}"
73
- @logger.info " Notification: #{@config[:notification]}"
74
- @logger.info " Max retries: #{@config[:max_retries]}"
75
- @logger.info " Min threads: #{@config[:min_threads]}"
76
- @logger.info " Max threads: #{@config[:max_threads]}"
77
- @logger.info " Environment: #{Rails.env}"
78
- @logger.info " Loglevel: #{@config[:loglevel]}"
79
- @logger.info " Logfile: #{@config[:logfile] || '(disabled)'}"
80
- @logger.info " Workers: #{@config[:workers]}"
81
- @logger.info " Queues: #{@config[:queues].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if @config[:queues].is_a?(Hash)
82
- @logger.info " PID: #{Process.pid}"
79
+ @logger.info "ActionCable notification: #{@config[:actioncable]}#{' (not available)' unless defined?(ActionCable)}"
80
+ @logger.info " ClassMethod extensions: #{@config[:extensions]}"
81
+ @logger.info " Purge completion: #{@config[:purge_completion]}"
82
+ @logger.info " Notification: #{@config[:notification]}"
83
+ @logger.info " Max retries: #{@config[:max_retries]}"
84
+ @logger.info " Min threads: #{@config[:min_threads]}"
85
+ @logger.info " Max threads: #{@config[:max_threads]}"
86
+ @logger.info " Environment: #{Rails.env}"
87
+ @logger.info " Loglevel: #{@config[:loglevel]}"
88
+ @logger.info " Logfile: #{@config[:logfile] || '(disabled)'}"
89
+ @logger.info " Workers: #{@config[:workers]}"
90
+ @logger.info " Queues: #{@config[:queues].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if @config[:queues].is_a?(Hash)
91
+ @logger.info " PID: #{Process.pid}"
83
92
  @logger.info "-"*(title.length)
84
93
  @logger.warn "[Skiplock] Custom notification has no registered 'on_error' callback" if Skiplock.on_errors.count == 0
85
94
  end
86
95
 
87
96
  def configure
88
- @hostname = Socket.gethostname
97
+ @hostname = "#{`hostname -f`.strip}|#{Socket.ip_address_list.reject(&:ipv4_loopback?).reject(&:ipv6?).map(&:ip_address).join('|')}"
89
98
  @config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
90
99
  @config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
91
100
  @config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
@@ -107,31 +116,26 @@ module Skiplock
107
116
  raise "Unable to detect any known exception notification library. Please define custom 'on_error' event callbacks and change to 'custom' notification in 'config/skiplock.yml'"
108
117
  end
109
118
  end
110
- Rails.application.eager_load! if Rails.env.development?
111
- if @config[:extensions] == true
112
- Module.__send__(:include, Skiplock::Extension)
113
- elsif @config[:extensions].is_a?(Array)
114
- @config[:extensions].each { |n| n.constantize.__send__(:extend, Skiplock::Extension) if n.safe_constantize }
115
- end
116
119
  case @config[:notification]
117
120
  when 'airbrake'
118
121
  raise 'airbrake gem not found' unless defined?(Airbrake)
119
- Skiplock.on_error do |ex, previous|
120
- Airbrake.notify_sync(ex) unless ex.backtrace == previous.try(:backtrace)
122
+ Skiplock.on_error do |ex|
123
+ Airbrake.notify_sync(ex)
121
124
  end
122
125
  when 'bugsnag'
123
126
  raise 'bugsnag gem not found' unless defined?(Bugsnag)
124
- Skiplock.on_error do |ex, previous|
125
- Bugsnag.notify(ex) unless ex.backtrace == previous.try(:backtrace)
127
+ Skiplock.on_error do |ex|
128
+ Bugsnag.notify(ex)
126
129
  end
127
130
  when 'exception_notification'
128
131
  raise 'exception_notification gem not found' unless defined?(ExceptionNotifier)
129
- Skiplock.on_error do |ex, previous|
130
- ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace)
132
+ Skiplock.on_error do |ex|
133
+ ExceptionNotifier.notify_exception(ex)
131
134
  end
132
135
  else
133
136
  @config[:notification] = 'custom'
134
137
  end
138
+ @logger.error 'ActionCable is not found!' if @config[:actioncable] && !defined?(ActionCable)
135
139
  Skiplock.on_errors.freeze
136
140
  end
137
141
 
@@ -144,6 +148,11 @@ module Skiplock
144
148
  @logger.extend(ActiveSupport::Logger.broadcast(::Logger.new(File.join(Rails.root, 'log', @config[:logfile].to_s), 'daily')))
145
149
  ActiveJob::Base.logger = nil
146
150
  end
151
+ if @config[:standalone]
152
+ Rails.logger.reopen('/dev/null') rescue Rails.logger.reopen('NUL') # supports Windows NUL device
153
+ Rails.logger.level = @logger.level
154
+ Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
155
+ end
147
156
  rescue Exception => ex
148
157
  @logger.error "Exception with logger: #{ex.to_s}"
149
158
  @logger.error ex.backtrace.join("\n")
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.20'
2
+ VERSION = Version = '1.0.24'
3
3
  end
4
4
 
@@ -12,107 +12,130 @@ module Skiplock
12
12
  self.where(id: delete_ids).delete_all if delete_ids.count > 0
13
13
  end
14
14
 
15
- def self.generate(capacity:, hostname:, master: true)
16
- self.create!(pid: Process.pid, sid: Process.getsid(), master: master, hostname: hostname, capacity: capacity)
15
+ def self.generate(capacity:, hostname:, master: true, actioncable: false)
16
+ worker = self.create!(pid: Process.pid, sid: Process.getsid(), master: master, hostname: hostname, capacity: capacity)
17
17
  rescue
18
- self.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: hostname, capacity: capacity)
19
- end
20
-
21
- def start(worker_num: 0, **config)
22
- if self.master
23
- Job.flush
24
- Cron.setup
25
- end
26
- @num = worker_num
27
- @config = config
28
- @queues_order_query = @config[:queues].map { |q,v| "WHEN queue_name = '#{q}' THEN #{v}" }.join(' ') if @config[:queues].is_a?(Hash) && @config[:queues].count > 0
29
- @running = true
30
- @executor = Concurrent::ThreadPoolExecutor.new(min_threads: @config[:min_threads] + 1, max_threads: @config[:max_threads] + 1, max_queue: @config[:max_threads], idletime: 60, auto_terminate: true, fallback_policy: :discard)
31
- @executor.post { run }
32
- Process.setproctitle("skiplock: #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2} [#{Rails.application.class.name.deconstantize.downcase}:#{Rails.env}]") if @config[:standalone]
18
+ worker = self.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: hostname, capacity: capacity)
19
+ ensure
20
+ ActionCable.server.broadcast('skiplock', { worker: { op: 'CREATE', id: worker.id, hostname: worker.hostname, master: worker.master, capacity: worker.capacity, pid: worker.pid, sid: worker.sid, created_at: worker.created_at.to_f, updated_at: worker.updated_at.to_f } }) if actioncable && defined?(ActionCable)
33
21
  end
34
22
 
35
23
  def shutdown
36
24
  @running = false
37
25
  @executor.shutdown
38
26
  @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
27
+ ActionCable.server.broadcast('skiplock', { worker: { op: 'DELETE', id: self.id, hostname: self.hostname, master: self.master, capacity: self.capacity, pid: self.pid, sid: self.sid, created_at: self.created_at.to_f, updated_at: self.updated_at.to_f } }) if @config[:actioncable] && defined?(ActionCable)
39
28
  self.delete
40
29
  Skiplock.logger.info "[Skiplock] Shutdown of #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2} (PID: #{self.pid}) was completed."
41
30
  end
42
31
 
32
+ def start(worker_num: 0, **config)
33
+ @num = worker_num
34
+ @config = config
35
+ @pg_config = ActiveRecord::Base.connection.raw_connection.conninfo_hash.compact
36
+ @queues_order_query = @config[:queues].map { |q,v| "WHEN queue_name = '#{q}' THEN #{v}" }.join(' ') if @config[:queues].is_a?(Hash) && @config[:queues].count > 0
37
+ @running = true
38
+ @map = ::PG::TypeMapByOid.new
39
+ @map.add_coder(::PG::TextDecoder::Boolean.new(oid: 16, name: 'bool'))
40
+ @map.add_coder(::PG::TextDecoder::Integer.new(oid: 20, name: 'int8'))
41
+ @map.add_coder(::PG::TextDecoder::Integer.new(oid: 21, name: 'int2'))
42
+ @map.add_coder(::PG::TextDecoder::Integer.new(oid: 23, name: 'int4'))
43
+ @map.add_coder(::PG::TextDecoder::TimestampUtc.new(oid: 1114, name: 'timestamp'))
44
+ @map.add_coder(::PG::TextDecoder::String.new(oid: 2950, name: 'uuid'))
45
+ @map.add_coder(::PG::TextDecoder::JSON.new(oid: 3802, name: 'jsonb'))
46
+ @executor = Concurrent::ThreadPoolExecutor.new(min_threads: @config[:min_threads] + 1, max_threads: @config[:max_threads] + 1, max_queue: @config[:max_threads] + 1, idletime: 60, auto_terminate: false, fallback_policy: :abort)
47
+ @executor.post { run }
48
+ if @config[:standalone]
49
+ Process.setproctitle("skiplock: #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2} [#{Rails.application.class.name.deconstantize.downcase}:#{Rails.env}]")
50
+ ActiveRecord::Base.connection.throw_away!
51
+ end
52
+ end
53
+
43
54
  private
44
55
 
56
+ def establish_connection
57
+ @connection = ::PG.connect(@pg_config)
58
+ @connection.type_map_for_results = @map
59
+ @connection.exec('LISTEN "skiplock::jobs"').clear
60
+ @connection.exec('LISTEN "skiplock::workers"').clear
61
+ end
62
+
45
63
  def run
46
64
  sleep 3
47
- ActiveRecord::Base.connection_pool.with_connection do |connection|
48
- Skiplock.logger.info "[Skiplock] Starting in #{@config[:standalone] ? 'standalone' : 'async'} mode (PID: #{self.pid}) with #{@config[:max_threads]} max threads as #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2}..."
49
- connection.exec_query('LISTEN "skiplock::jobs"')
50
- error = false
51
- next_schedule_at = Time.now.to_f
52
- timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
53
- while @running
54
- Rails.application.reloader.wrap do
55
- begin
56
- if error
57
- unless connection.active?
58
- connection.reconnect!
59
- sleep(0.5)
60
- connection.exec_query('LISTEN "skiplock::jobs"')
61
- next_schedule_at = Time.now.to_f
65
+ Skiplock.logger.info "[Skiplock] Starting in #{@config[:standalone] ? 'standalone' : 'async'} mode (PID: #{self.pid}) with #{@config[:max_threads]} max threads as #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2}..."
66
+ next_schedule_at = Time.now.to_f
67
+ pg_exception_timestamp = nil
68
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
+ while @running
70
+ Rails.application.reloader.wrap do
71
+ begin
72
+ if @connection.nil? || @connection.status != ::PG::CONNECTION_OK
73
+ establish_connection
74
+ @executor.post { Rails.application.executor.wrap { Job.flush } } if self.master
75
+ pg_exception_timestamp = nil
76
+ next_schedule_at = Time.now.to_f
77
+ end
78
+ if Time.now.to_f >= next_schedule_at && @executor.remaining_capacity > 1 # reserves 1 slot in queue for Job.flush in case of pg_connection error
79
+ result = nil
80
+ @connection.transaction do |conn|
81
+ conn.exec("SELECT id, running, scheduled_at FROM skiplock.jobs WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL ORDER BY scheduled_at ASC NULLS FIRST,#{@queues_order_query ? ' CASE ' + @queues_order_query + ' ELSE NULL END ASC NULLS LAST,' : ''} priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1") do |r|
82
+ result = r.first
83
+ conn.exec("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{result['id']}' RETURNING *") { |r| result = r.first } if result && result['scheduled_at'].to_f <= Time.now.to_f
62
84
  end
63
- Job.flush if self.master
64
- error = false
65
85
  end
66
- if Time.now.to_f >= next_schedule_at && @executor.remaining_capacity > 0
67
- job = nil
68
- connection.transaction do
69
- result = connection.select_all("SELECT id, running, scheduled_at FROM skiplock.jobs WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL ORDER BY scheduled_at ASC NULLS FIRST,#{@queues_order_query ? ' CASE ' + @queues_order_query + ' ELSE NULL END ASC NULLS LAST,' : ''} priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").first
70
- result = connection.select_all("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{result['id']}' RETURNING *").first if result && result['scheduled_at'].to_f <= Time.now.to_f
71
- job = Job.instantiate(result) if result
72
- end
73
- if job.try(:running)
74
- @executor.post do
75
- Rails.application.executor.wrap { job.execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) }
76
- end
77
- else
78
- next_schedule_at = (job ? job.scheduled_at.to_f : Float::INFINITY)
79
- end
86
+ if result && result['running']
87
+ @executor.post { Rails.application.executor.wrap { Job.instantiate(result).execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) } }
88
+ else
89
+ next_schedule_at = (result ? result['scheduled_at'].to_f : Float::INFINITY)
80
90
  end
81
- job_notifications = []
82
- connection.raw_connection.wait_for_notify(0.2) do |channel, pid, payload|
83
- job_notifications << payload if payload
84
- loop do
85
- payload = connection.raw_connection.notifies
86
- break unless @running && payload
87
- job_notifications << payload[:extra]
88
- end
89
- job_notifications.each do |n|
90
- op, id, worker_id, job_class, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
91
- next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0
92
- next_schedule_at = scheduled_at.to_f if scheduled_at.to_f < next_schedule_at
93
- end
91
+ end
92
+ notifications = { 'skiplock::jobs' => [], 'skiplock::workers' => [] }
93
+ @connection.wait_for_notify(0.2) do |channel, pid, payload|
94
+ notifications[channel] << payload if payload
95
+ loop do
96
+ payload = @connection.notifies
97
+ break unless @running && payload
98
+ notifications[payload[:relname]] << payload[:extra]
99
+ end
100
+ notifications['skiplock::jobs'].each do |n|
101
+ op, id, worker_id, job_class, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
102
+ ActionCable.server.broadcast('skiplock', { job: { op: op, id: id, worker_id: worker_id, job_class: job_class, queue_name: queue_name, running: (running == 'true'), expired_at: expired_at.to_f, finished_at: finished_at.to_f, scheduled_at: scheduled_at.to_f } }) if self.master && @config[:actioncable] && defined?(ActionCable)
103
+ next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0
104
+ next_schedule_at = scheduled_at.to_f if scheduled_at.to_f < next_schedule_at
94
105
  end
95
- if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
96
- self.touch
97
- timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
+ if self.master && @config[:actioncable] && defined?(ActionCable)
107
+ notifications['skiplock::workers'].each do |w|
108
+ op, id, hostname, master, capacity, pid, sid, created_at, updated_at = w.split(',')
109
+ ActionCable.server.broadcast('skiplock', { worker: { op: op, id: id, hostname: hostname, master: (master == 'true'), capacity: capacity.to_i, pid: pid.to_i, sid: sid.to_i, created_at: created_at.to_f, updated_at: updated_at.to_f } })
110
+ end
98
111
  end
99
- rescue Exception => ex
100
- # most likely error with database connection
112
+ end
113
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
114
+ @connection.exec("UPDATE skiplock.workers SET updated_at = NOW() WHERE id = '#{self.id}'").clear
115
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
116
+ end
117
+ rescue Exception => ex
118
+ report_exception = true
119
+ # if error is with database connection then only report if it persists longer than 1 minute
120
+ if @connection.nil? || @connection.status != ::PG::CONNECTION_OK
121
+ report_exception = false if pg_exception_timestamp.nil? || Process.clock_gettime(Process::CLOCK_MONOTONIC) - pg_exception_timestamp <= 60
122
+ pg_exception_timestamp ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
123
+ end
124
+ if report_exception
101
125
  Skiplock.logger.error(ex.to_s)
102
126
  Skiplock.logger.error(ex.backtrace.join("\n"))
103
- Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
104
- error = true
105
- wait(5)
106
- @last_exception = ex
127
+ Skiplock.on_errors.each { |p| p.call(ex) }
107
128
  end
108
- sleep(0.3)
129
+ wait(5)
109
130
  end
131
+ sleep(0.3)
110
132
  end
111
- connection.exec_query('UNLISTEN *')
112
133
  end
134
+ ensure
135
+ @connection.close if @connection && !@connection.finished?
113
136
  end
114
137
 
115
- def wait(timeout)
138
+ def wait(timeout = 1)
116
139
  t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
117
140
  while @running
118
141
  sleep(0.5)
data/lib/skiplock.rb CHANGED
@@ -11,7 +11,7 @@ require 'skiplock/worker'
11
11
  require 'skiplock/version'
12
12
 
13
13
  module Skiplock
14
- DEFAULT_CONFIG = { 'extensions' => false, 'logfile' => 'skiplock.log', 'loglevel' => 'info', 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 10, 'max_retries' => 20, 'notification' => 'custom', 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
14
+ DEFAULT_CONFIG = { 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 10, 'max_retries' => 20, 'logfile' => 'skiplock.log', 'loglevel' => 'info', 'notification' => 'custom', 'actioncable' => false, 'extensions' => false, 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
15
15
 
16
16
  def self.logger=(l)
17
17
  @logger = l
@@ -34,5 +34,4 @@ module Skiplock
34
34
  def self.table_name_prefix
35
35
  'skiplock.'
36
36
  end
37
- end
38
- ActiveJob::Base.__send__(:include, Skiplock::Patch)
37
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: skiplock
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.20
4
+ version: 1.0.24
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tin Vo
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-09-14 00:00:00.000000000 Z
11
+ date: 2021-09-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob