skiplock 1.0.21 → 1.0.25

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: 69e771cf5508d4a5a648dab43eb33b980370e317a1bf370ceaff62ecee4592e3
4
- data.tar.gz: d1df71a515d871073b8d039f1bcbc3fb00b398806ed9f3bd82dc0169737e2f52
3
+ metadata.gz: 2e920cb349a73036c794a785a955013644642fbe543d1d36605ee20e2db99e99
4
+ data.tar.gz: 9b4d8971fe38e5e13d5378f2781249b77aefd68dc03ab978ef7daa8ccf1b1c5a
5
5
  SHA512:
6
- metadata.gz: cca4a86fc4c31e4054a920b32740f294e3f54e9de65fe3a0c31b43258fee6e6f1a790ae1a95f79d9b719119936fedac953afd695ef5e7ce9e990b7f29fa1c397
7
- data.tar.gz: 6bdc5a38218b052a825ab2b5f5ef2c73a2c9a6a2d981d8aed7cc61244f8806fdad06876278aa320bafb956c9dc2f15b705d87a2f171cbb2bc3805bfada640ce4
6
+ metadata.gz: 363a9cad645d4e01ae82fd692717bf237b36399049858196cd36df95d6d0acdc86f0c0b0e2fd7dda17a083a9bf5044134466e90baffc87620e125bb1a6c0b856
7
+ data.tar.gz: 6b1af9d83854a982430959bef45dab748f9b8697f35a3a121465f5d0ba484634c12b3744606c89c5009e3cc634f7b8f8481fccb00eccc0aa07a2c5d93b09f5e2
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  It only uses the `LISTEN/NOTIFY/SKIP LOCKED` features provided natively on PostgreSQL 9.5+ to efficiently and reliably dispatch jobs to worker processes and threads ensuring that each job can be completed successfully **only once**. No other polling or timer is needed.
6
6
 
7
- The library is quite small compared to other PostgreSQL job queues (eg. *delay_job*, *queue_classic*, *que*, *good_job*) with less than 500 lines of codes; and it still provides similar set of features and more...
7
+ The library is quite small compared to other PostgreSQL job queues (eg. *delay_job*, *queue_classic*, *que*, *good_job*) with less than 600 lines of codes; and it still provides similar set of features and more...
8
8
 
9
9
  #### Compatibility:
10
10
 
@@ -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
@@ -237,6 +243,10 @@ Code examples of gathering counters information:
237
243
  ```ruby
238
244
  Skiplock::Counter.sum(:expiries)
239
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
+ ```
240
250
 
241
251
  ## Contributing
242
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,12 +116,6 @@ 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)
@@ -132,6 +135,7 @@ module Skiplock
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,10 +148,17 @@ 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
+ # disable ActionCable logging
156
+ ActionCable.server.config.logger = Logger.new(nil) if defined?(ActionCable)
157
+ end
147
158
  rescue Exception => ex
148
159
  @logger.error "Exception with logger: #{ex.to_s}"
149
160
  @logger.error ex.backtrace.join("\n")
150
161
  Skiplock.on_errors.each { |p| p.call(ex) }
151
162
  end
152
163
  end
153
- end
164
+ end
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.21'
2
+ VERSION = Version = '1.0.25'
3
3
  end
4
4
 
@@ -12,112 +12,129 @@ 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)
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)
19
21
  end
20
22
 
21
23
  def shutdown
22
24
  @running = false
23
25
  @executor.shutdown
24
26
  @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
25
- self.delete
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 self.delete && @config[:actioncable] && defined?(ActionCable)
26
28
  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."
27
29
  end
28
30
 
29
31
  def start(worker_num: 0, **config)
30
32
  @num = worker_num
31
33
  @config = config
34
+ @pg_config = ActiveRecord::Base.connection.raw_connection.conninfo_hash.compact
32
35
  @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
33
36
  @running = true
34
- @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: true, fallback_policy: :discard)
35
- if self.master
36
- Job.flush
37
- Cron.setup
38
- end
37
+ @map = ::PG::TypeMapByOid.new
38
+ @map.add_coder(::PG::TextDecoder::Boolean.new(oid: 16, name: 'bool'))
39
+ @map.add_coder(::PG::TextDecoder::Integer.new(oid: 20, name: 'int8'))
40
+ @map.add_coder(::PG::TextDecoder::Integer.new(oid: 21, name: 'int2'))
41
+ @map.add_coder(::PG::TextDecoder::Integer.new(oid: 23, name: 'int4'))
42
+ @map.add_coder(::PG::TextDecoder::TimestampUtc.new(oid: 1114, name: 'timestamp'))
43
+ @map.add_coder(::PG::TextDecoder::String.new(oid: 2950, name: 'uuid'))
44
+ @map.add_coder(::PG::TextDecoder::JSON.new(oid: 3802, name: 'jsonb'))
45
+ @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)
39
46
  @executor.post { run }
40
- 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]
47
+ if @config[:standalone]
48
+ 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}]")
49
+ ActiveRecord::Base.connection.throw_away!
50
+ end
41
51
  end
42
52
 
43
53
  private
44
54
 
45
- def reloader_post
46
- Rails.application.reloader.wrap { @executor.post { Rails.application.executor.wrap { yield } } } if block_given?
55
+ def establish_connection
56
+ @connection = ::PG.connect(@pg_config)
57
+ @connection.type_map_for_results = @map
58
+ @connection.exec('LISTEN "skiplock::jobs"').clear
59
+ @connection.exec('LISTEN "skiplock::workers"').clear
47
60
  end
48
61
 
49
62
  def run
50
63
  sleep 3
51
64
  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}..."
52
- error = false
53
65
  next_schedule_at = Time.now.to_f
54
66
  pg_exception_timestamp = nil
55
67
  timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
56
- ActiveRecord::Base.connection_pool.with_connection do |connection|
57
- connection.exec_query('LISTEN "skiplock::jobs"')
58
- while @running
68
+ while @running
69
+ Rails.application.reloader.wrap do
59
70
  begin
60
- if error
61
- unless connection.active?
62
- connection.reconnect!
63
- sleep(0.5)
64
- connection.exec_query('LISTEN "skiplock::jobs"')
65
- reloader_post { Job.flush } if self.master
66
- pg_exception_timestamp = nil
67
- next_schedule_at = Time.now.to_f
68
- end
69
- error = false
71
+ if @connection.nil? || @connection.status != ::PG::CONNECTION_OK
72
+ establish_connection
73
+ @executor.post { Rails.application.executor.wrap { Job.flush } } if self.master
74
+ pg_exception_timestamp = nil
75
+ next_schedule_at = Time.now.to_f
70
76
  end
71
77
  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
72
78
  result = nil
73
- connection.transaction do
74
- 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
75
- 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
79
+ @connection.transaction do |conn|
80
+ 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|
81
+ result = r.first
82
+ 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
83
+ end
76
84
  end
77
85
  if result && result['running']
78
- reloader_post { Job.instantiate(result).execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) }
86
+ @executor.post { Rails.application.executor.wrap { Job.instantiate(result).execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) } }
79
87
  else
80
88
  next_schedule_at = (result ? result['scheduled_at'].to_f : Float::INFINITY)
81
89
  end
82
90
  end
83
- job_notifications = []
84
- connection.raw_connection.wait_for_notify(0.2) do |channel, pid, payload|
85
- job_notifications << payload if payload
91
+ notifications = { 'skiplock::jobs' => [], 'skiplock::workers' => [] }
92
+ @connection.wait_for_notify(0.2) do |channel, pid, payload|
93
+ notifications[channel] << payload if payload
86
94
  loop do
87
- payload = connection.raw_connection.notifies
95
+ payload = @connection.notifies
88
96
  break unless @running && payload
89
- job_notifications << payload[:extra]
97
+ notifications[payload[:relname]] << payload[:extra]
90
98
  end
91
- job_notifications.each do |n|
99
+ notifications['skiplock::jobs'].each do |n|
92
100
  op, id, worker_id, job_class, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
101
+ 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)
93
102
  next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0
94
103
  next_schedule_at = scheduled_at.to_f if scheduled_at.to_f < next_schedule_at
95
104
  end
105
+ if self.master && @config[:actioncable] && defined?(ActionCable)
106
+ notifications['skiplock::workers'].each do |w|
107
+ op, id, hostname, master, capacity, pid, sid, created_at, updated_at = w.split(',')
108
+ 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 } })
109
+ end
110
+ end
96
111
  end
97
112
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
98
- connection.exec_query("UPDATE skiplock.workers SET updated_at = NOW() WHERE id = '#{self.id}'")
113
+ @connection.exec("UPDATE skiplock.workers SET updated_at = NOW() WHERE id = '#{self.id}'").clear
99
114
  timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
100
115
  end
101
116
  rescue Exception => ex
102
- Skiplock.logger.error(ex.to_s)
103
- Skiplock.logger.error(ex.backtrace.join("\n"))
104
117
  report_exception = true
105
118
  # if error is with database connection then only report if it persists longer than 1 minute
106
- if ex.is_a?(::PG::ConnectionBad)
119
+ if @connection.nil? || @connection.status != ::PG::CONNECTION_OK
107
120
  report_exception = false if pg_exception_timestamp.nil? || Process.clock_gettime(Process::CLOCK_MONOTONIC) - pg_exception_timestamp <= 60
108
121
  pg_exception_timestamp ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
122
  end
110
- Skiplock.on_errors.each { |p| p.call(ex) } if report_exception
111
- error = true
123
+ if report_exception
124
+ Skiplock.logger.error(ex.to_s)
125
+ Skiplock.logger.error(ex.backtrace.join("\n"))
126
+ Skiplock.on_errors.each { |p| p.call(ex) }
127
+ end
112
128
  wait(5)
113
129
  end
114
130
  sleep(0.3)
115
131
  end
116
- connection.exec_query('UNLISTEN *')
117
132
  end
133
+ ensure
134
+ @connection.close if @connection && !@connection.finished?
118
135
  end
119
136
 
120
- def wait(timeout)
137
+ def wait(timeout = 1)
121
138
  t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
122
139
  while @running
123
140
  sleep(0.5)
@@ -125,4 +142,4 @@ module Skiplock
125
142
  end
126
143
  end
127
144
  end
128
- end
145
+ end
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.21
4
+ version: 1.0.25
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-16 00:00:00.000000000 Z
11
+ date: 2021-11-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob