skiplock 1.0.20 → 1.0.24

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