skiplock 1.0.18 → 1.0.23

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: 8112589dcbdf58a743529ec9bf5ddd8371791e83e79c0d455ed42cbca05ee5eb
4
- data.tar.gz: 149f826e93ecd8ac6165a8db46a8e7e234636a4481e68bf09d208b04a1dea1ce
3
+ metadata.gz: 275e1ddd1a59648e86378889e2e2cc6a4e22943a3fb4211209f20a674744b1b3
4
+ data.tar.gz: 7327f5893f36cc4b05163cfd0df47992344b2ca6b97c2ef10ca4f00b219a9ac9
5
5
  SHA512:
6
- metadata.gz: 437f622f2398a0a6c965ea1720f77f11b9241373f277e249e4f3d39afbd3bccc7d8ad2b919abd141ebc5bc6a2543408a7940e53f2968c3cda9fc0519ac387910
7
- data.tar.gz: '0473939dc2497842ff66fb02e2f74371f7281d58cc5ac82f1999b669f5b45e526d5d75054a3cc95594e26fc5f4e4aafc42e15d2f7336b88223256d6f7ac46d4e'
6
+ metadata.gz: e9b8afd563ec4ff46838e73ae031a75a69142ee392a05d0e5b612008153b10a6a62a931e22643e817722d0017548573f9cafed6ba943f41da3cf8d0872d76481
7
+ data.tar.gz: 8b04173649d9e4a21edb9d47e23d37d5c8d0b08ba8658f46c0fd8341270abf1148a2131e3f57251421caf024d5ce8d9bfb35a648ee9a904f999d6b21bf0fe5ec
data/README.md CHANGED
@@ -85,6 +85,7 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
85
85
  Usage: skiplock [options]
86
86
  -e, --environment STRING Rails environment
87
87
  -l, --logfile STRING Log filename
88
+ -L, --loglevel STRING Log level (debug, info, warn, error, fatal, unknown)
88
89
  -s, --graceful-shutdown NUM Number of seconds to wait for graceful shutdown
89
90
  -r, --max-retries NUM Number of maxixum retries
90
91
  -t, --max-threads NUM Number of maximum threads
@@ -172,14 +173,10 @@ If the `retry_on` block is not defined, then the built-in retry system of `Skipl
172
173
  `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
174
  ```ruby
174
175
  # 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
176
+ Skiplock.on_error do |ex|
177
+ # sends text message using Amazon SNS
178
+ 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])
179
+ sms.publish(phone_number: '+12223334444', message: "Exception: #{ex.message}"[0..130])
183
180
  end
184
181
  # supports multiple 'on_error' event callbacks
185
182
  ```
@@ -201,9 +198,9 @@ To enable extension for specific classes and modules only then set the configura
201
198
  ```ruby
202
199
  Session.skiplock(wait: 5.minutes, queue: 'maintenance').cleanup
203
200
  ```
204
- - Queue class method `charge` of class `Subscription` as background job to run tomorrow at noon
201
+ - Queue class method `charge` of class `Subscription` as background job to run tomorrow at noon without purging
205
202
  ```ruby
206
- Subscription.skiplock(wait_until: Date.tomorrow.noon).charge(amount: 100)
203
+ Subscription.skiplock(purge: false, wait_until: Date.tomorrow.noon).charge(amount: 100)
207
204
  ```
208
205
 
209
206
  ## Fault tolerant
data/bin/skiplock CHANGED
@@ -7,6 +7,7 @@ begin
7
7
  opts.banner = "Usage: #{File.basename($0)} [options]"
8
8
  opts.on('-e', '--environment STRING', String, 'Rails environment')
9
9
  opts.on('-l', '--logfile STRING', String, 'Log filename')
10
+ opts.on('-L', '--loglevel STRING', String, 'Log level (debug, info, warn, error, fatal, unknown)')
10
11
  opts.on('-s', '--graceful-shutdown NUM', Integer, 'Number of seconds to wait for graceful shutdown')
11
12
  opts.on('-r', '--max-retries NUM', Integer, 'Number of maxixum retries')
12
13
  opts.on('-t', '--max-threads NUM', Integer, 'Number of maximum threads')
@@ -46,18 +46,20 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
46
46
  IF (record.running = TRUE) THEN
47
47
  INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
48
48
  END IF;
49
- ELSIF (record.running = TRUE) THEN
50
- IF (record.executions > 0) THEN
51
- INSERT INTO skiplock.counters (day,retries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET retries = skiplock.counters.retries + 1;
52
- ELSE
53
- INSERT INTO skiplock.counters (day,dispatches) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET dispatches = skiplock.counters.dispatches + 1;
49
+ ELSIF (TG_OP = 'UPDATE') THEN
50
+ IF (OLD.running = FALSE AND record.running = TRUE) THEN
51
+ IF (record.executions > 0) THEN
52
+ INSERT INTO skiplock.counters (day,retries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET retries = skiplock.counters.retries + 1;
53
+ ELSE
54
+ INSERT INTO skiplock.counters (day,dispatches) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET dispatches = skiplock.counters.dispatches + 1;
55
+ END IF;
56
+ ELSIF (OLD.finished_at IS NULL AND record.finished_at IS NOT NULL) THEN
57
+ INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
58
+ ELSIF (OLD.running = TRUE AND record.running = FALSE AND record.expired_at IS NOT NULL) THEN
59
+ INSERT INTO skiplock.counters (day,expiries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET expiries = skiplock.counters.expiries + 1;
60
+ ELSIF (OLD.running = TRUE AND record.running = FALSE AND record.expired_at IS NULL AND record.finished_at IS NULL) THEN
61
+ INSERT INTO skiplock.counters (day,failures) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET failures = skiplock.counters.failures + 1;
54
62
  END IF;
55
- ELSIF (record.finished_at IS NOT NULL) THEN
56
- INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
57
- ELSIF (record.expired_at IS NOT NULL) THEN
58
- INSERT INTO skiplock.counters (day,expiries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET expiries = skiplock.counters.expiries + 1;
59
- ELSIF (TG_OP = 'UPDATE' AND OLD.running = TRUE AND NEW.running = FALSE) THEN
60
- INSERT INTO skiplock.counters (day,failures) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET failures = skiplock.counters.failures + 1;
61
63
  END IF;
62
64
  PERFORM pg_notify('skiplock::jobs', CONCAT(TG_OP,',',record.id::TEXT,',',record.worker_id::TEXT,',',record.job_class,',',record.queue_name,',',record.running::TEXT,',',CAST(EXTRACT(EPOCH FROM record.expired_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.finished_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM CASE WHEN record.scheduled_at IS NULL THEN record.updated_at ELSE record.scheduled_at END) AS FLOAT)::TEXT));
63
65
  RETURN NULL;
@@ -85,7 +87,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
85
87
  execute "CREATE INDEX jobs_retry_index ON skiplock.jobs(scheduled_at) WHERE running = FALSE AND executions IS NOT NULL AND expired_at IS NULL AND finished_at IS NULL"
86
88
  execute "CREATE INDEX jobs_cron_index ON skiplock.jobs(scheduled_at ASC NULLS FIRST, priority ASC NULLS LAST, created_at ASC) WHERE cron IS NOT NULL AND finished_at IS NULL"
87
89
  execute "CREATE UNIQUE INDEX jobs_unique_cron_index ON skiplock.jobs (job_class) WHERE cron IS NOT NULL"
88
- execute "CREATE UNIQUE INDEX workers_unique_master_index ON skiplock.workers(hostname) WHERE master = 't'"
90
+ execute "CREATE UNIQUE INDEX workers_unique_master_index ON skiplock.workers(hostname) WHERE master = TRUE"
89
91
  end
90
92
 
91
93
  def down
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_retry
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)
@@ -19,16 +22,16 @@ module Skiplock
19
22
  end
20
23
 
21
24
  def self.enqueue_at(activejob, timestamp)
25
+ options = activejob.instance_variable_get('@skiplock_options') || {}
22
26
  timestamp = Time.at(timestamp) if timestamp
23
27
  if Thread.current[:skiplock_job].try(:id) == activejob.job_id
24
- Thread.current[:skiplock_job].activejob_retry = true
25
- Thread.current[:skiplock_job].data['activejob_retry'] = true
28
+ Thread.current[:skiplock_job].activejob_error = options[:error]
29
+ Thread.current[:skiplock_job].data['activejob_error'] = true
26
30
  Thread.current[:skiplock_job].executions = activejob.executions
27
31
  Thread.current[:skiplock_job].exception_executions = activejob.exception_executions
28
32
  Thread.current[:skiplock_job].scheduled_at = timestamp
29
33
  Thread.current[:skiplock_job]
30
34
  else
31
- options = activejob.instance_variable_get('@skiplock_options') || {}
32
35
  serialize = activejob.serialize
33
36
  self.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'], 'options' => options }, 'scheduled_at' => timestamp))
34
37
  end
@@ -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.activejob_retry
65
- if (self.executions.to_i >= @max_retries + 1) || self.data.key?('activejob_retry') || @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"))
@@ -103,14 +106,15 @@ module Skiplock
103
106
 
104
107
  def execute(purge_completion: true, max_retries: 20)
105
108
  raise 'Job has already been completed' if self.finished_at
109
+ self.update_columns(running: true, updated_at: Time.now) unless self.running
106
110
  Skiplock.logger.info("[Skiplock] Performing #{self.job_class} (#{self.id}) from queue '#{self.queue_name || 'default'}'...") if Skiplock.logger
107
111
  self.data ||= {}
108
112
  self.data.delete('result')
109
113
  self.exception_executions ||= {}
110
- self.activejob_retry = false
111
- @max_retries = (self.data['options'].key?('max_retries') ? self.data['options']['max_retries'].to_i : max_retries) rescue max_retries
112
- @max_retries = 20 if @max_retries < 0 || @max_retries > 20
113
- @purge = (self.data['options'].key?('purge') ? self.data['options']['purge'] : purge_completion) rescue purge_completion
114
+ self.activejob_error = nil
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
114
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'] || []))
115
119
  self.executions = self.executions.to_i + 1
116
120
  Thread.current[:skiplock_job] = self
@@ -118,15 +122,15 @@ module Skiplock
118
122
  begin
119
123
  self.data['result'] = ActiveJob::Base.execute(job_data)
120
124
  rescue Exception => ex
121
- @exception = ex
122
- Skiplock.on_errors.each { |p| p.call(@exception) }
125
+ self.exception = ex
126
+ Skiplock.on_errors.each { |p| p.call(ex) }
123
127
  end
124
128
  if Skiplock.logger
125
- if @exception || self.activejob_retry
126
- Skiplock.logger.error("[Skiplock] Job #{self.job_class} (#{self.id}) was interrupted by an exception#{ ' (rescued and retried by ActiveJob)' if self.activejob_retry }")
127
- if @exception
128
- Skiplock.logger.error(@exception.to_s)
129
- Skiplock.logger.error(@exception.backtrace.join("\n"))
129
+ if self.exception || self.activejob_error
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 }")
131
+ if self.exception
132
+ Skiplock.logger.error(self.exception.to_s)
133
+ Skiplock.logger.error(self.exception.backtrace.join("\n"))
130
134
  end
131
135
  else
132
136
  end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -138,8 +142,9 @@ module Skiplock
138
142
  Skiplock.logger.info "[Skiplock] Performed #{job_name} (#{self.id}) from queue '#{self.queue_name || 'default'}' in #{end_time - start_time} seconds"
139
143
  end
140
144
  end
145
+ self.exception || self.activejob_error || self.data['result']
141
146
  ensure
142
- self.finished_at ||= Time.now if self.data.key?('result') && !self.activejob_retry
147
+ self.finished_at ||= Time.now if self.data.key?('result') && !self.activejob_error
143
148
  self.dispose
144
149
  end
145
150
  end
@@ -4,16 +4,23 @@ 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
- @config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
8
- @hostname = Socket.gethostname
9
- configure
10
- setup_logger
11
- if (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0)
12
- Worker.cleanup(@hostname)
13
- @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
14
- @worker.start(**@config)
15
- at_exit { @worker.shutdown }
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 }
16
12
  end
13
+ (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0) ? async : Cron.setup
14
+ end
15
+
16
+ def async
17
+ setup_logger
18
+ configure
19
+ Worker.cleanup(@hostname)
20
+ @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
21
+ Cron.setup if @worker.master
22
+ @worker.start(**@config)
23
+ at_exit { @worker.shutdown }
17
24
  rescue Exception => ex
18
25
  @logger.error(ex.to_s)
19
26
  @logger.error(ex.backtrace.join("\n"))
@@ -21,21 +28,23 @@ module Skiplock
21
28
 
22
29
  def standalone(**options)
23
30
  @config.merge!(options)
24
- Rails.logger.reopen('/dev/null') rescue Rails.logger.reopen('NUL') # supports Windows NUL device
25
- Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
26
- @config[:workers] = 1 if @config[:workers] <= 0
27
31
  @config[:standalone] = true
32
+ @config[:workers] = 1 if @config[:workers] <= 0
33
+ setup_logger
34
+ configure
28
35
  banner
29
- Worker.cleanup(@hostname)
30
- @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
31
36
  @parent_id = Process.pid
32
37
  @shutdown = false
33
38
  Signal.trap('INT') { @shutdown = true }
34
39
  Signal.trap('TERM') { @shutdown = true }
35
40
  Signal.trap('HUP') { setup_logger }
41
+ Worker.cleanup(@hostname)
42
+ @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
43
+ ActiveRecord::Base.connection.disconnect! if @config[:workers] > 1
36
44
  (@config[:workers] - 1).times do |n|
37
45
  fork do
38
- sleep 1
46
+ sleep(0.25*n + 1)
47
+ ActiveRecord::Base.establish_connection
39
48
  worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname, master: false)
40
49
  worker.start(worker_num: n + 1, **@config)
41
50
  loop do
@@ -45,6 +54,7 @@ module Skiplock
45
54
  worker.shutdown
46
55
  end
47
56
  end
57
+ ActiveRecord::Base.establish_connection if @config[:workers] > 1
48
58
  @worker.start(**@config)
49
59
  loop do
50
60
  sleep 0.5
@@ -53,7 +63,9 @@ module Skiplock
53
63
  @logger.info "[Skiplock] Terminating signal... Waiting for jobs to finish (up to #{@config[:graceful_shutdown]} seconds)..." if @config[:graceful_shutdown]
54
64
  Process.waitall
55
65
  @worker.shutdown
56
- @logger.info "[Skiplock] Shutdown completed."
66
+ rescue Exception => ex
67
+ @logger.error(ex.to_s)
68
+ @logger.error(ex.backtrace.join("\n"))
57
69
  end
58
70
 
59
71
  private
@@ -80,6 +92,8 @@ module Skiplock
80
92
  end
81
93
 
82
94
  def configure
95
+ @hostname = Socket.ip_address_list.reject(&:ipv4_loopback?).reject(&:ipv6?).map(&:ip_address).join('|')
96
+ @config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
83
97
  @config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
84
98
  @config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
85
99
  @config[:max_retries] = 20 if @config[:max_retries] > 20
@@ -103,40 +117,39 @@ module Skiplock
103
117
  case @config[:notification]
104
118
  when 'airbrake'
105
119
  raise 'airbrake gem not found' unless defined?(Airbrake)
106
- Skiplock.on_error do |ex, previous|
107
- Airbrake.notify_sync(ex) unless ex.backtrace == previous.try(:backtrace)
120
+ Skiplock.on_error do |ex|
121
+ Airbrake.notify_sync(ex)
108
122
  end
109
123
  when 'bugsnag'
110
124
  raise 'bugsnag gem not found' unless defined?(Bugsnag)
111
- Skiplock.on_error do |ex, previous|
112
- Bugsnag.notify(ex) unless ex.backtrace == previous.try(:backtrace)
125
+ Skiplock.on_error do |ex|
126
+ Bugsnag.notify(ex)
113
127
  end
114
128
  when 'exception_notification'
115
129
  raise 'exception_notification gem not found' unless defined?(ExceptionNotifier)
116
- Skiplock.on_error do |ex, previous|
117
- ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace)
130
+ Skiplock.on_error do |ex|
131
+ ExceptionNotifier.notify_exception(ex)
118
132
  end
119
133
  else
120
134
  @config[:notification] = 'custom'
121
135
  end
122
- Rails.application.eager_load! if Rails.env.development?
123
- if @config[:extensions] == true
124
- Module.__send__(:include, Skiplock::Extension)
125
- elsif @config[:extensions].is_a?(Array)
126
- @config[:extensions].each { |n| n.constantize.__send__(:extend, Skiplock::Extension) if n.safe_constantize }
127
- end
128
- Skiplock.on_errors.freeze unless Skiplock.on_errors.frozen?
136
+ Skiplock.on_errors.freeze
129
137
  end
130
138
 
131
139
  def setup_logger
132
- @config[:loglevel] = 'info' unless ['debug','info','warn','error','fatal','unknown'].include?(@config[:loglevel].to_s.downcase)
140
+ @config[:loglevel] = 'info' unless ['debug','info','warn','error','fatal','unknown'].include?(@config[:loglevel].to_s)
133
141
  @logger = ActiveSupport::Logger.new(STDOUT)
134
- @logger.level = @config[:loglevel].downcase.to_sym
142
+ @logger.level = @config[:loglevel].to_sym
135
143
  Skiplock.logger = @logger
136
144
  if @config[:logfile].to_s.length > 0
137
145
  @logger.extend(ActiveSupport::Logger.broadcast(::Logger.new(File.join(Rails.root, 'log', @config[:logfile].to_s), 'daily')))
138
146
  ActiveJob::Base.logger = nil
139
147
  end
148
+ if @config[:standalone]
149
+ Rails.logger.reopen('/dev/null') rescue Rails.logger.reopen('NUL') # supports Windows NUL device
150
+ Rails.logger.level = @logger.level
151
+ Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
152
+ end
140
153
  rescue Exception => ex
141
154
  @logger.error "Exception with logger: #{ex.to_s}"
142
155
  @logger.error ex.backtrace.join("\n")
@@ -1,7 +1,7 @@
1
1
  module Skiplock
2
2
  module Patch
3
3
  def enqueue(options = {})
4
- self.instance_variable_set('@skiplock_options', options)
4
+ @skiplock_options = options
5
5
  super
6
6
  end
7
7
  end
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.18'
2
+ VERSION = Version = '1.0.23'
3
3
  end
4
4
 
@@ -18,74 +18,78 @@ module Skiplock
18
18
  self.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: hostname, capacity: capacity)
19
19
  end
20
20
 
21
- def start(worker_num: 0, **config)
22
- if self.master
23
- Job.flush
24
- Cron.setup
25
- end
26
- @config = config
27
- @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
28
- @running = true
29
- @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)
30
- @executor.post { run }
31
- Process.setproctitle("skiplock-#{self.master ? 'master[0]' : 'worker[' + worker_num.to_s + ']'}") if @config[:standalone]
32
- end
33
-
34
21
  def shutdown
35
22
  @running = false
36
23
  @executor.shutdown
37
24
  @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
38
25
  self.delete
26
+ 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
+ end
28
+
29
+ def start(worker_num: 0, **config)
30
+ @num = worker_num
31
+ @config = config
32
+ @pg_config = ActiveRecord::Base.connection.raw_connection.conninfo_hash.compact
33
+ @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
34
+ @running = true
35
+ @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: :abort)
36
+ @executor.post { run }
37
+ if @config[:standalone]
38
+ 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}]")
39
+ ActiveRecord::Base.connection.throw_away!
40
+ end
39
41
  end
40
42
 
41
43
  private
42
44
 
43
- def get_next_available_job
44
- @connection.transaction do
45
- job = Job.find_by_sql("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
46
- if job && job.scheduled_at.to_f <= Time.now.to_f
47
- job = Job.find_by_sql("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
48
- end
49
- job
50
- end
45
+ def establish_connection
46
+ map = ::PG::TypeMapByOid.new
47
+ map.add_coder(::PG::TextDecoder::Boolean.new(oid: 16, name: 'bool'))
48
+ map.add_coder(::PG::TextDecoder::Integer.new(oid: 20, name: 'int8'))
49
+ map.add_coder(::PG::TextDecoder::Integer.new(oid: 21, name: 'int2'))
50
+ map.add_coder(::PG::TextDecoder::Integer.new(oid: 23, name: 'int4'))
51
+ map.add_coder(::PG::TextDecoder::TimestampUtc.new(oid: 1114, name: 'timestamp'))
52
+ map.add_coder(::PG::TextDecoder::String.new(oid: 2950, name: 'uuid'))
53
+ map.add_coder(::PG::TextDecoder::JSON.new(oid: 3802, name: 'jsonb'))
54
+ @connection = ::PG.connect(@pg_config)
55
+ @connection.type_map_for_results = map
56
+ @connection.exec('LISTEN "skiplock::jobs"').clear
51
57
  end
52
58
 
53
59
  def run
54
- error = false
55
- listen = false
60
+ sleep 3
61
+ 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}..."
56
62
  next_schedule_at = Time.now.to_f
63
+ pg_exception_timestamp = nil
57
64
  timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
58
65
  while @running
59
66
  Rails.application.reloader.wrap do
60
67
  begin
61
- unless listen
62
- @connection = self.class.connection
63
- @connection.exec_query('LISTEN "skiplock::jobs"')
64
- listen = true
68
+ if @connection.nil? || @connection.status != ::PG::CONNECTION_OK
69
+ establish_connection
70
+ @executor.post { Rails.application.executor.wrap { Job.flush } } if self.master
71
+ pg_exception_timestamp = nil
72
+ next_schedule_at = Time.now.to_f
65
73
  end
66
- if error
67
- unless @connection.active?
68
- @connection.reconnect!
69
- sleep(0.5)
70
- @connection.exec_query('LISTEN "skiplock::jobs"')
71
- next_schedule_at = Time.now.to_f
74
+ 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
75
+ result = nil
76
+ @connection.transaction do |conn|
77
+ 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|
78
+ result = r.first
79
+ 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
80
+ end
72
81
  end
73
- Job.flush if self.master
74
- error = false
75
- end
76
- if Time.now.to_f >= next_schedule_at && @executor.remaining_capacity > 0
77
- job = get_next_available_job
78
- if job.try(:running)
79
- @executor.post { Rails.application.reloader.wrap { job.execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) } }
82
+ if result && result['running']
83
+ @executor.post { Rails.application.executor.wrap { Job.instantiate(result).execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) } }
80
84
  else
81
- next_schedule_at = (job ? job.scheduled_at.to_f : Float::INFINITY)
85
+ next_schedule_at = (result ? result['scheduled_at'].to_f : Float::INFINITY)
82
86
  end
83
87
  end
84
88
  job_notifications = []
85
- @connection.raw_connection.wait_for_notify(0.4) do |channel, pid, payload|
89
+ @connection.wait_for_notify(0.2) do |channel, pid, payload|
86
90
  job_notifications << payload if payload
87
91
  loop do
88
- payload = @connection.raw_connection.notifies
92
+ payload = @connection.notifies
89
93
  break unless @running && payload
90
94
  job_notifications << payload[:extra]
91
95
  end
@@ -96,25 +100,31 @@ module Skiplock
96
100
  end
97
101
  end
98
102
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
99
- self.touch
103
+ @connection.exec("UPDATE skiplock.workers SET updated_at = NOW() WHERE id = '#{self.id}'").clear
100
104
  timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
105
  end
102
106
  rescue Exception => ex
103
- # most likely error with database connection
104
- Skiplock.logger.error(ex.to_s)
105
- Skiplock.logger.error(ex.backtrace.join("\n"))
106
- Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
107
- error = true
107
+ report_exception = true
108
+ # if error is with database connection then only report if it persists longer than 1 minute
109
+ if @connection.nil? || @connection.status != ::PG::CONNECTION_OK
110
+ report_exception = false if pg_exception_timestamp.nil? || Process.clock_gettime(Process::CLOCK_MONOTONIC) - pg_exception_timestamp <= 60
111
+ pg_exception_timestamp ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
112
+ end
113
+ if report_exception
114
+ Skiplock.logger.error(ex.to_s)
115
+ Skiplock.logger.error(ex.backtrace.join("\n"))
116
+ Skiplock.on_errors.each { |p| p.call(ex) }
117
+ end
108
118
  wait(5)
109
- @last_exception = ex
110
119
  end
111
120
  sleep(0.3)
112
121
  end
113
122
  end
114
- @connection.exec_query('UNLISTEN *')
123
+ ensure
124
+ @connection.close if @connection && !@connection.finished?
115
125
  end
116
126
 
117
- def wait(timeout)
127
+ def wait(timeout = 1)
118
128
  t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
119
129
  while @running
120
130
  sleep(0.5)
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.18
4
+ version: 1.0.23
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-12 00:00:00.000000000 Z
11
+ date: 2021-09-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -109,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
109
  - !ruby/object:Gem::Version
110
110
  version: '0'
111
111
  requirements: []
112
- rubygems_version: 3.0.3
112
+ rubygems_version: 3.2.22
113
113
  signing_key:
114
114
  specification_version: 4
115
115
  summary: ActiveJob Queue Adapter for PostgreSQL