skiplock 1.0.10 → 1.0.14

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: 681b41b73f6ddf95620515f3d83454ba1f623d57740248bf84041829c225318d
4
- data.tar.gz: 0433b2e635174ae052b67d7502e4b2477c54736f79ec0acf0ad369a1be976256
3
+ metadata.gz: 55c98833e2792a216b6389d399e992aae9660f23ea076fcf852fb164cbc32d55
4
+ data.tar.gz: 43c8c2f90ee55ec423242042d1ab2986c0cb9f70fcc04db56aaf2c566bbb0382
5
5
  SHA512:
6
- metadata.gz: 6860b6cff70a8881277fd0ce7d2d76d211723e8c1974a1e2b70db7ea31b05268af6d38b68f2437cc9144d0d896751de205e21890219d3664b0f5ae128e1c6ab0
7
- data.tar.gz: ddf11fbb1b030dc437254f87142127fd135bacbb84301d03fbcb14e3029bc05736e9c02b1d08b6be8f2d6b90e3fbfdf8eff568c63c8d8c9b451c730a1a125b39
6
+ metadata.gz: 873492b815e6b5fc653630fed3bc64c5a363339551bbad304ecbad9453ea3738c24f3f0b874f4d6fe47cd4cc0ea5d46fb61b4c0dc322814d08dbc11002c24443
7
+ data.tar.gz: e84f7081e07ba0aff19834251de09e08df573d45ff159299fe08baf621b76845cd2fdf0461229da77b07cf707f151e7d8fc00d97b19a9bf30f1129376c2dd053
data/README.md CHANGED
@@ -50,12 +50,13 @@ 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
- extensions: false
54
- logging: true
55
53
  min_threads: 1
56
54
  max_threads: 5
57
55
  max_retries: 20
58
- notification: none
56
+ logfile: log/skiplock.log
57
+ loglevel: info
58
+ notification: custom
59
+ extensions: false
59
60
  purge_completion: true
60
61
  queues:
61
62
  default: 200
@@ -63,12 +64,13 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
63
64
  workers: 0
64
65
  ```
65
66
  Available configuration options are:
66
- - **extensions** (*boolean*): enable or disable the class method extension. See `ClassMethod extension` for more details
67
- - **logging** (*boolean*): enable or disable file logging capability; the log file is stored at log/skiplock.log
68
67
  - **min_threads** (*integer*): sets minimum number of threads staying idle
69
68
  - **max_threads** (*integer*): sets the maximum number of threads allowed to run jobs
70
- - **max_retries** (*integer*): sets the maximum attempt a job will be retrying before it is marked expired. See `Retry System` for more details
71
- - **notification** (*enumeration*): sets the library to be used for notifying errors and exceptions (`auto, airbrake, bugsnag, exception_notification, none`). Using `auto` will attempt to detect available gems in the application
69
+ - **max_retries** (*integer*): sets the maximum attempt a job will be retrying before it is marked expired. See `Retry system` for more details
70
+ - **logfile** (*string*): path filename for skiplock logs; empty logfile will disable logging
71
+ - **loglevel** (*string*): sets logging level (`debug, info, warn, error, fatal, unknown`)
72
+ - **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
73
+ - **extensions** (*boolean*): enable or disable the class method extension. See `ClassMethod extension` for more details
72
74
  - **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)
73
75
  - **queues** (*hash*): defines the set of queues with priorities; lower priority takes precedence
74
76
  - **workers** (*integer*) sets the maximum number of processes when running in standalone mode using the `skiplock` executable; setting this to **0** will enable **async mode**
@@ -82,7 +84,7 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
82
84
  $ bundle exec skiplock -h
83
85
  Usage: skiplock [options]
84
86
  -e, --environment STRING Rails environment
85
- -l, --logging STRING Possible values: true, false, timestamp
87
+ -l, --logfile STRING Full path to logfile
86
88
  -s, --graceful-shutdown NUM Number of seconds to wait for graceful shutdown
87
89
  -r, --max-retries NUM Number of maxixum retries
88
90
  -t, --max-threads NUM Number of maximum threads
@@ -158,17 +160,17 @@ If the retry attempt limit configured in ActiveJob has been reached, then the co
158
160
  If the `retry_on` block is not defined, then the built-in retry system of `skiplock` will kick in automatically. The retrying schedule is using an exponential formula (5 + 2**attempt). The `skiplock` configuration `max_retries` determines the the limit of attempts before the failing job is marked as expired. The maximum retry limit can be set as high as 20; this allows up to 12 days of retrying before the job is marked as expired.
159
161
 
160
162
  ## Notification system
161
- `Skiplock` can use existing exception notification library to notify errors and exceptions. It supports `airbrake`, `bugsnag`, and `exception_notification` as shown in the **Configuration** section above. Custom function can also be called whenever an exception occurs; it can be configured in an initializer like below:
163
+ `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:
162
164
  ```ruby
163
165
  # config/initializers/skiplock.rb
164
166
  Skiplock.on_error do |ex, previous|
165
167
  if ex.backtrace != previous.try(:backtrace)
166
168
  # sends custom email on new exceptions only
167
169
  # the same repeated exceptions will only be sent once to avoid SPAM
168
- # NOTE: exceptions generated from Job perform method executions will not provide 'previous' exceptions
170
+ # NOTE: exceptions generated from Job executions will not provide 'previous' exceptions
169
171
  end
170
172
  end
171
- # supports multiple on_error callbacks
173
+ # supports multiple 'on_error' event callbacks
172
174
  ```
173
175
  ## ClassMethod extension
174
176
  `Skiplock` can add extension to allow all class methods to be performed as a background job; it is disabled in the default configuration. To enable, edit the `config/skiplock.yml` configuration file and change `extensions` to `true`.
data/bin/skiplock CHANGED
@@ -5,7 +5,7 @@ begin
5
5
  op = OptionParser.new do |opts|
6
6
  opts.banner = "Usage: #{File.basename($0)} [options]"
7
7
  opts.on('-e', '--environment STRING', String, 'Rails environment')
8
- opts.on('-l', '--logging STRING', String, 'Possible values: true, false, timestamp')
8
+ opts.on('-l', '--logfile STRING', String, 'Full path to logfile')
9
9
  opts.on('-s', '--graceful-shutdown NUM', Integer, 'Number of seconds to wait for graceful shutdown')
10
10
  opts.on('-r', '--max-retries NUM', Integer, 'Number of maxixum retries')
11
11
  opts.on('-t', '--max-threads NUM', Integer, 'Number of maximum threads')
@@ -25,4 +25,4 @@ options.transform_keys! { |k| k.to_s.gsub('-', '_').to_sym }
25
25
  env = options.delete(:environment)
26
26
  ENV['RAILS_ENV'] = env if env
27
27
  require File.expand_path("config/environment.rb")
28
- Skiplock::Manager.new(options.merge(standalone: true))
28
+ Rails.application.config.skiplock.standalone(**options.merge(standalone: true))
@@ -2,7 +2,7 @@ module ActiveJob
2
2
  module QueueAdapters
3
3
  class SkiplockAdapter
4
4
  def initialize
5
- Rails.application.config.after_initialize { Skiplock::Manager.new }
5
+ Rails.application.config.after_initialize { Rails.application.config.skiplock = Skiplock::Manager.new }
6
6
  end
7
7
 
8
8
  def enqueue(job)
@@ -60,7 +60,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
60
60
  ELSIF (record.executions IS NOT NULL AND record.scheduled_at IS NOT NULL) THEN
61
61
  INSERT INTO skiplock.counters (day,failures) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET failures = skiplock.counters.failures + 1;
62
62
  END IF;
63
- PERFORM pg_notify('skiplock::jobs', CONCAT(TG_OP,',',record.id::TEXT,',',record.worker_id::TEXT,',',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
+ 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));
64
64
  RETURN NULL;
65
65
  END;
66
66
  $$ LANGUAGE plpgsql
data/lib/skiplock/cron.rb CHANGED
@@ -2,6 +2,7 @@ require 'cron_parser'
2
2
  module Skiplock
3
3
  class Cron
4
4
  def self.setup
5
+ Rails.application.eager_load! if Rails.env.development?
5
6
  cronjobs = []
6
7
  ActiveJob::Base.descendants.each do |j|
7
8
  next unless j.const_defined?('CRON')
data/lib/skiplock/job.rb CHANGED
@@ -2,35 +2,14 @@ module Skiplock
2
2
  class Job < ActiveRecord::Base
3
3
  self.implicit_order_column = 'created_at'
4
4
 
5
- def self.dispatch(queues_order_query: nil, worker_id: nil, purge_completion: true, max_retries: 20)
5
+ def self.dispatch(worker_id: nil, purge_completion: true, max_retries: 20)
6
6
  job = nil
7
- self.transaction do
8
- job = self.find_by_sql("SELECT id, scheduled_at FROM #{self.table_name} 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
9
- return (job ? job.scheduled_at.to_f : Float::INFINITY) if job.nil? || job.scheduled_at.to_f > Time.now.to_f
10
- job = Skiplock::Job.find_by_sql("UPDATE #{self.table_name} SET running = TRUE, worker_id = #{self.connection.quote(worker_id)}, updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
7
+ self.connection.transaction do
8
+ job = self.find_by_sql("SELECT id, 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, priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").first
9
+ return if job.nil? || job.scheduled_at.to_f > Time.now.to_f
10
+ job = self.find_by_sql("UPDATE skiplock.jobs SET running = TRUE, worker_id = #{self.connection.quote(worker_id)}, updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
11
11
  end
12
- job.data ||= {}
13
- job.exception_executions ||= {}
14
- job_data = job.attributes.slice('job_class', 'queue_name', 'locale', 'timezone', 'priority', 'executions', 'exception_executions').merge('job_id' => job.id, 'enqueued_at' => job.updated_at, 'arguments' => (job.data['arguments'] || []))
15
- job.executions = (job.executions || 0) + 1
16
- Skiplock.logger.info "[Skiplock] Performing #{job.job_class} (#{job.id}) from queue '#{job.queue_name || 'default'}'..."
17
- Thread.current[:skiplock_dispatch_job] = job
18
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
- begin
20
- ActiveJob::Base.execute(job_data)
21
- rescue Exception => ex
22
- Skiplock.logger.error(ex)
23
- end
24
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
- job_name = job.job_class
26
- if job.job_class == 'Skiplock::Extension::ProxyJob'
27
- target, method_name = ::YAML.load(job.data['arguments'].first)
28
- job_name = "'#{target.name}.#{method_name}'"
29
- end
30
- Skiplock.logger.info "[Skiplock] Performed #{job_name} (#{job.id}) from queue '#{job.queue_name || 'default'}' in #{end_time - start_time} seconds"
31
- job.dispose(ex, purge_completion: purge_completion, max_retries: max_retries)
32
- ensure
33
- Thread.current[:skiplock_dispatch_job] = nil
12
+ self.dispatch(worker_id: worker_id, purge_completion: purge_completion, max_retries: max_retries) if job.execute(purge_completion: purge_completion, max_retries: max_retries)
34
13
  end
35
14
 
36
15
  def self.enqueue(activejob)
@@ -46,7 +25,7 @@ module Skiplock
46
25
  Thread.current[:skiplock_dispatch_job]
47
26
  else
48
27
  serialize = activejob.serialize
49
- Job.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'] }, 'scheduled_at' => timestamp))
28
+ self.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'] }, 'scheduled_at' => timestamp))
50
29
  end
51
30
  end
52
31
 
@@ -55,8 +34,9 @@ module Skiplock
55
34
  end
56
35
 
57
36
  def dispose(ex, purge_completion: true, max_retries: 20)
58
- dup = self.dup
37
+ yaml = [self, ex].to_yaml
59
38
  self.running = false
39
+ self.worker_id = nil
60
40
  self.updated_at = (Time.now > self.updated_at ? Time.now : self.updated_at + 1)
61
41
  if ex
62
42
  self.exception_executions["[#{ex.class.name}]"] = (self.exception_executions["[#{ex.class.name}]"] || 0) + 1 unless self.exception_executions.key?('activejob_retry')
@@ -80,6 +60,7 @@ module Skiplock
80
60
  self.scheduled_at = Time.at(next_cron_at)
81
61
  self.save!
82
62
  else
63
+ Skiplock.logger.error("[Skiplock] ERROR: Invalid CRON '#{self.cron}' for Job #{self.job_class}") if Skiplock.logger
83
64
  self.delete
84
65
  end
85
66
  elsif purge_completion
@@ -90,9 +71,46 @@ module Skiplock
90
71
  self.save!
91
72
  end
92
73
  self
93
- rescue
94
- File.write("tmp/skiplock/#{self.id}", [dup, ex].to_yaml)
74
+ rescue Exception => e
75
+ if Skiplock.logger
76
+ Skiplock.logger.error(e.to_s)
77
+ Skiplock.logger.error(e.backtrace.join("\n"))
78
+ File.write("tmp/skiplock/#{self.id}", yaml)
79
+ end
95
80
  nil
96
81
  end
82
+
83
+ def execute(purge_completion: true, max_retries: 20)
84
+ Skiplock.logger.info("[Skiplock] Performing #{self.job_class} (#{self.id}) from queue '#{self.queue_name || 'default'}'...") if Skiplock.logger
85
+ self.data ||= {}
86
+ self.exception_executions ||= {}
87
+ 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'] || []))
88
+ self.executions = (self.executions || 0) + 1
89
+ Thread.current[:skiplock_dispatch_job] = self
90
+ activejob = ActiveJob::Base.deserialize(job_data)
91
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
92
+ begin
93
+ activejob.perform_now
94
+ rescue Exception => ex
95
+ end
96
+ if Skiplock.logger
97
+ if ex || self.exception_executions.key?('activejob_retry')
98
+ Skiplock.logger.error("[Skiplock] Job #{self.job_class} (#{self.id}) was interrupted by an exception#{ ' (rescued and retried by ActiveJob)' if self.exception_executions.key?('activejob_retry') }")
99
+ if ex
100
+ Skiplock.logger.error(ex.to_s)
101
+ Skiplock.logger.error(ex.backtrace.join("\n"))
102
+ end
103
+ else
104
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
105
+ job_name = self.job_class
106
+ if self.job_class == 'Skiplock::Extension::ProxyJob'
107
+ target, method_name = ::YAML.load(self.data['arguments'].first)
108
+ job_name = "'#{target.name}.#{method_name}'"
109
+ end
110
+ Skiplock.logger.info "[Skiplock] Performed #{job_name} (#{self.id}) from queue '#{self.queue_name || 'default'}' in #{end_time - start_time} seconds"
111
+ end
112
+ end
113
+ self.dispose(ex, purge_completion: purge_completion, max_retries: max_retries)
114
+ end
97
115
  end
98
116
  end
@@ -6,46 +6,76 @@ module Skiplock
6
6
  @config.symbolize_keys!
7
7
  @config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
8
8
  @config.merge!(config)
9
- Module.__send__(:include, Skiplock::Extension) if @config[:extensions] == true
10
- return unless @config[:standalone] || (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0)
11
9
  @config[:hostname] = `hostname -f`.strip
12
- do_config
10
+ configure
11
+ Module.__send__(:include, Skiplock::Extension) if @config[:extensions] == true
12
+ if (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0)
13
+ cleanup_workers
14
+ @worker = create_worker
15
+ @worker.start(**@config)
16
+ at_exit { @worker.shutdown }
17
+ end
18
+ rescue Exception => ex
19
+ @logger.error(ex.to_s)
20
+ @logger.error(ex.backtrace.join("\n"))
21
+ end
22
+
23
+ def standalone(**options)
24
+ @config.merge!(options)
25
+ Rails.logger.reopen('/dev/null')
26
+ Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
27
+ @config[:workers] = 1 if @config[:workers] <= 0
13
28
  banner
14
29
  cleanup_workers
15
- create_worker
16
- ActiveJob::Base.logger = nil
17
- if @config[:standalone]
18
- standalone
19
- else
20
- dispatcher = Dispatcher.new(worker: @worker, **@config)
21
- thread = dispatcher.run
22
- at_exit do
23
- dispatcher.shutdown
24
- thread.join(@config[:graceful_shutdown])
25
- @worker.delete
30
+ @worker = create_worker
31
+ @parent_id = Process.pid
32
+ @shutdown = false
33
+ Signal.trap("INT") { @shutdown = true }
34
+ Signal.trap("TERM") { @shutdown = true }
35
+ (@config[:workers] - 1).times do |n|
36
+ fork do
37
+ sleep 1
38
+ worker = create_worker(master: false)
39
+ worker.start(worker_num: n + 1, **@config)
40
+ loop do
41
+ sleep 0.5
42
+ break if @shutdown || Process.ppid != @parent_id
43
+ end
44
+ worker.shutdown
26
45
  end
27
46
  end
47
+ @worker.start(**@config)
48
+ loop do
49
+ sleep 0.5
50
+ break if @shutdown
51
+ end
52
+ @logger.info "[Skiplock] Terminating signal... Waiting for jobs to finish (up to #{@config[:graceful_shutdown]} seconds)..." if @config[:graceful_shutdown]
53
+ Process.waitall
54
+ @worker.shutdown
55
+ @logger.info "[Skiplock] Shutdown completed."
28
56
  end
29
57
 
30
- private
58
+ private
31
59
 
32
60
  def banner
33
- title = "[Skiplock] V#{Skiplock::VERSION} (Rails #{Rails::VERSION::STRING} | Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
61
+ title = "Skiplock #{Skiplock::VERSION} (Rails #{Rails::VERSION::STRING} | Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
34
62
  @logger.info "-"*(title.length)
35
63
  @logger.info title
36
64
  @logger.info "-"*(title.length)
37
- @logger.info "ClassMethod Extensions: #{@config[:extensions]}"
65
+ @logger.info "ClassMethod extensions: #{@config[:extensions]}"
38
66
  @logger.info " Purge completion: #{@config[:purge_completion]}"
39
- @logger.info " Notification: #{@config[:notification]}#{(' (' + @notification + ')') if @config[:notification] == 'auto'}"
67
+ @logger.info " Notification: #{@config[:notification]}"
40
68
  @logger.info " Max retries: #{@config[:max_retries]}"
41
69
  @logger.info " Min threads: #{@config[:min_threads]}"
42
70
  @logger.info " Max threads: #{@config[:max_threads]}"
43
71
  @logger.info " Environment: #{Rails.env}"
44
- @logger.info " Logging: #{@config[:logging]}"
72
+ @logger.info " Loglevel: #{@config[:loglevel]}"
73
+ @logger.info " Logfile: #{@config[:logfile] || '(disabled)'}"
45
74
  @logger.info " Workers: #{@config[:workers]}"
46
75
  @logger.info " Queues: #{@config[:queues].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if @config[:queues].is_a?(Hash)
47
76
  @logger.info " PID: #{Process.pid}"
48
77
  @logger.info "-"*(title.length)
78
+ @logger.warn "[Skiplock] Custom notification has no registered 'on_error' callback" if Skiplock.on_errors.count == 0
49
79
  end
50
80
 
51
81
  def cleanup_workers
@@ -54,19 +84,18 @@ module Skiplock
54
84
  sid = Process.getsid(worker.pid) rescue nil
55
85
  delete_ids << worker.id if worker.sid != sid || worker.updated_at < 30.minutes.ago
56
86
  end
57
- if delete_ids.count > 0
58
- Job.where(running: true, worker_id: delete_ids).update_all(running: false, worker_id: nil)
59
- Worker.where(id: delete_ids).delete_all
60
- end
87
+ Worker.where(id: delete_ids).delete_all if delete_ids.count > 0
88
+ Job.where(running: true).where.not(worker_id: Worker.ids).update_all(running: false, worker_id: nil)
61
89
  end
62
90
 
63
- def create_worker(pid: Process.pid, sid: Process.getsid(), master: true)
64
- @worker = Worker.create!(pid: pid, sid: sid, master: master, hostname: @config[:hostname], capacity: @config[:max_threads])
91
+ def create_worker(master: true)
92
+ Worker.create!(pid: Process.pid, sid: Process.getsid(), master: master, hostname: @config[:hostname], capacity: @config[:max_threads])
65
93
  rescue
66
- @worker = Worker.create!(pid: pid, sid: sid, master: false, hostname: @config[:hostname], capacity: @config[:max_threads])
94
+ Worker.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: @config[:hostname], capacity: @config[:max_threads])
67
95
  end
68
96
 
69
- def do_config
97
+ def configure
98
+ @config[:loglevel] = 'info' unless ['debug','info','warn','error','fatal','unknown'].include?(@config[:loglevel].to_s)
70
99
  @config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
71
100
  @config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
72
101
  @config[:max_retries] = 20 if @config[:max_retries] > 20
@@ -75,22 +104,28 @@ module Skiplock
75
104
  @config[:max_threads] = 20 if @config[:max_threads] > 20
76
105
  @config[:min_threads] = 0 if @config[:min_threads] < 0
77
106
  @config[:workers] = 0 if @config[:workers] < 0
78
- @config[:workers] = 1 if @config[:standalone] && @config[:workers] <= 0
107
+ @logger = ActiveSupport::Logger.new(STDOUT)
108
+ @logger.level = @config[:loglevel].to_sym
109
+ Skiplock.logger = @logger
110
+ raise "Cannot create logfile '#{@config[:logfile]}'" if @config[:logfile] && !File.writable?(File.dirname(@config[:logfile]))
111
+ @config[:logfile] = nil if @config[:logfile].to_s.length == 0
112
+ if @config[:logfile]
113
+ @logger.extend(ActiveSupport::Logger.broadcast(::Logger.new(@config[:logfile])))
114
+ ActiveJob::Base.logger = nil
115
+ end
79
116
  @config[:queues].values.each { |v| raise 'Queue value must be an integer' unless v.is_a?(Integer) } if @config[:queues].is_a?(Hash)
80
- @notification = @config[:notification]
81
- if @notification == 'auto'
117
+ if @config[:notification] == 'auto'
82
118
  if defined?(Airbrake)
83
- @notification = 'airbrake'
119
+ @config[:notification] = 'airbrake'
84
120
  elsif defined?(Bugsnag)
85
- @notification = 'bugsnag'
121
+ @config[:notification] = 'bugsnag'
86
122
  elsif defined?(ExceptionNotifier)
87
- @notification = 'exception_notification'
123
+ @config[:notification] = 'exception_notification'
88
124
  else
89
- @logger.info "Unable to detect any known exception notification gem. Please define custom 'on_error' callback function and disable 'auto' notification in 'config/skiplock.yml'"
90
- exit
125
+ 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'"
91
126
  end
92
127
  end
93
- case @notification
128
+ case @config[:notification]
94
129
  when 'airbrake'
95
130
  raise 'airbrake gem not found' unless defined?(Airbrake)
96
131
  Skiplock.on_error do |ex, previous|
@@ -106,53 +141,10 @@ module Skiplock
106
141
  Skiplock.on_error do |ex, previous|
107
142
  ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace)
108
143
  end
109
- end
110
- Skiplock.logger = ActiveSupport::Logger.new(STDOUT)
111
- Skiplock.logger.level = Rails.logger.level
112
- @logger = Skiplock.logger
113
- if @config[:logging]
114
- Skiplock.logger.extend(ActiveSupport::Logger.broadcast(::Logger.new('log/skiplock.log')))
115
- if @config[:standalone]
116
- Rails.logger.reopen('/dev/null')
117
- Rails.logger.extend(ActiveSupport::Logger.broadcast(Skiplock.logger))
118
- end
144
+ else
145
+ @config[:notification] = 'custom'
119
146
  end
120
147
  Skiplock.on_errors.freeze unless Skiplock.on_errors.frozen?
121
148
  end
122
-
123
- def standalone
124
- parent_id = Process.pid
125
- shutdown = false
126
- Signal.trap("INT") { shutdown = true }
127
- Signal.trap("TERM") { shutdown = true }
128
- (@config[:workers] - 1).times do |n|
129
- fork do
130
- sleep 1
131
- worker = create_worker(master: false)
132
- dispatcher = Dispatcher.new(worker: worker, worker_num: n + 1, **@config)
133
- thread = dispatcher.run
134
- loop do
135
- sleep 0.5
136
- break if shutdown || Process.ppid != parent_id
137
- end
138
- dispatcher.shutdown
139
- thread.join(@config[:graceful_shutdown])
140
- worker.delete
141
- exit
142
- end
143
- end
144
- dispatcher = Dispatcher.new(worker: @worker, **@config)
145
- thread = dispatcher.run
146
- loop do
147
- sleep 0.5
148
- break if shutdown
149
- end
150
- @logger.info "[Skiplock] Terminating signal... Waiting for jobs to finish (up to #{@config[:graceful_shutdown]} seconds)..." if @config[:graceful_shutdown]
151
- Process.waitall
152
- dispatcher.shutdown
153
- thread.join(@config[:graceful_shutdown])
154
- @worker.delete
155
- @logger.info "[Skiplock] Shutdown completed."
156
- end
157
149
  end
158
150
  end
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.10'
2
+ VERSION = Version = '1.0.14'
3
3
  end
4
4
 
@@ -1,5 +1,121 @@
1
1
  module Skiplock
2
2
  class Worker < ActiveRecord::Base
3
3
  self.implicit_order_column = 'created_at'
4
+
5
+ def start(worker_num: 0, **config)
6
+ @config = config
7
+ @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
8
+ @next_schedule_at = Time.now.to_f
9
+ @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)
10
+ @running = true
11
+ Process.setproctitle("skiplock-#{self.master ? 'master[0]' : 'worker[' + worker_num.to_s + ']'}") if @config[:standalone]
12
+ @executor.post do
13
+ Rails.application.reloader.wrap { run }
14
+ end
15
+ end
16
+
17
+ def shutdown
18
+ @running = false
19
+ @executor.shutdown
20
+ @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
21
+ self.delete
22
+ end
23
+
24
+ private
25
+
26
+ def check_sync_errors
27
+ # get executed jobs that could not sync with database
28
+ Dir.glob('tmp/skiplock/*').each do |f|
29
+ job_from_db = Job.find_by(id: File.basename(f), running: true)
30
+ disposed = true
31
+ if job_from_db
32
+ job, ex = YAML.load_file(f) rescue nil
33
+ disposed = job.dispose(ex, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) if job
34
+ end
35
+ File.delete(f) if disposed
36
+ end
37
+ end
38
+
39
+ def get_next_available_job
40
+ @connection.transaction do
41
+ job = Job.find_by_sql("SELECT id, 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
42
+ if job && job.scheduled_at.to_f <= Time.now.to_f
43
+ job = Job.find_by_sql("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
44
+ end
45
+ job
46
+ end
47
+ end
48
+
49
+ def run
50
+ @connection = self.class.connection
51
+ @connection.exec_query('LISTEN "skiplock::jobs"')
52
+ if self.master
53
+ Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
54
+ check_sync_errors
55
+ Cron.setup
56
+ end
57
+ error = false
58
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
+ while @running
60
+ Rails.application.reloader.wrap do
61
+ begin
62
+ if error
63
+ unless @connection.active?
64
+ @connection.reconnect!
65
+ sleep(0.5)
66
+ @connection.exec_query('LISTEN "skiplock::jobs"')
67
+ @next_schedule_at = Time.now.to_f
68
+ end
69
+ check_sync_errors if self.master
70
+ error = false
71
+ end
72
+ if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
73
+ job = get_next_available_job
74
+ if job.try(:running)
75
+ @executor.post do
76
+ Rails.application.executor.wrap do
77
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads { job.execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) }
78
+ end
79
+ end
80
+ else
81
+ @next_schedule_at = (job ? job.scheduled_at.to_f : Float::INFINITY)
82
+ end
83
+ end
84
+ job_notifications = []
85
+ @connection.raw_connection.wait_for_notify(0.4) do |channel, pid, payload|
86
+ job_notifications << payload if payload
87
+ loop do
88
+ payload = @connection.raw_connection.notifies
89
+ break unless @running && payload
90
+ job_notifications << payload[:extra]
91
+ end
92
+ job_notifications.each do |n|
93
+ op, id, worker_id, job_class, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
94
+ next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0
95
+ @next_schedule_at = scheduled_at.to_f if scheduled_at.to_f < @next_schedule_at
96
+ end
97
+ end
98
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
99
+ self.touch
100
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
+ end
102
+ 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
108
+ t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
+ while @running
110
+ sleep(0.5)
111
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - t > 5
112
+ end
113
+ @last_exception = ex
114
+ end
115
+ sleep(0.3)
116
+ end
117
+ end
118
+ @connection.exec_query('UNLISTEN *')
119
+ end
4
120
  end
5
121
  end
data/lib/skiplock.rb CHANGED
@@ -3,7 +3,6 @@ require 'active_job/queue_adapters/skiplock_adapter'
3
3
  require 'active_record'
4
4
  require 'skiplock/counter'
5
5
  require 'skiplock/cron'
6
- require 'skiplock/dispatcher'
7
6
  require 'skiplock/extension'
8
7
  require 'skiplock/job'
9
8
  require 'skiplock/manager'
@@ -11,7 +10,7 @@ require 'skiplock/worker'
11
10
  require 'skiplock/version'
12
11
 
13
12
  module Skiplock
14
- DEFAULT_CONFIG = { 'extensions' => false, 'logging' => true, 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 5, 'max_retries' => 20, 'notification' => 'none', 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
13
+ DEFAULT_CONFIG = { 'extensions' => false, 'logfile' => 'log/skiplock.log', 'loglevel' => 'info', 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 5, 'max_retries' => 20, 'notification' => 'custom', 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
15
14
 
16
15
  def self.logger=(l)
17
16
  @logger = l
@@ -28,7 +27,7 @@ module Skiplock
28
27
  end
29
28
 
30
29
  def self.on_errors
31
- @on_errors || [].freeze
30
+ @on_errors || []
32
31
  end
33
32
 
34
33
  def self.table_name_prefix
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.10
4
+ version: 1.0.14
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-08-29 00:00:00.000000000 Z
11
+ date: 2021-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -84,7 +84,6 @@ files:
84
84
  - lib/skiplock.rb
85
85
  - lib/skiplock/counter.rb
86
86
  - lib/skiplock/cron.rb
87
- - lib/skiplock/dispatcher.rb
88
87
  - lib/skiplock/extension.rb
89
88
  - lib/skiplock/job.rb
90
89
  - lib/skiplock/manager.rb
@@ -1,117 +0,0 @@
1
- module Skiplock
2
- class Dispatcher
3
- def initialize(worker:, worker_num: nil, **config)
4
- @config = config
5
- @worker = worker
6
- @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
7
- @executor = Concurrent::ThreadPoolExecutor.new(min_threads: @config[:min_threads], max_threads: @config[:max_threads], max_queue: @config[:max_threads], idletime: 60, auto_terminate: true, fallback_policy: :discard)
8
- @last_dispatch_at = 0
9
- @next_schedule_at = Time.now.to_f
10
- Process.setproctitle("skiplock-#{@worker.master ? 'master[0]' : 'worker[' + @worker_num.to_s + ']'}") if @config[:standalone]
11
- end
12
-
13
- def run
14
- @running = true
15
- Thread.new do
16
- ActiveRecord::Base.connection_pool.with_connection do |connection|
17
- connection.exec_query('LISTEN "skiplock::jobs"')
18
- if @worker.master
19
- Rails.application.eager_load! if Rails.env.development?
20
- Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
21
- check_sync_errors
22
- Cron.setup
23
- end
24
- error = false
25
- timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
- while @running
27
- begin
28
- if error
29
- unless connection.active?
30
- connection.reconnect!
31
- sleep(0.5)
32
- connection.exec_query('LISTEN "skiplock::jobs"')
33
- @next_schedule_at = Time.now.to_f
34
- end
35
- check_sync_errors
36
- error = false
37
- end
38
- job_notifications = []
39
- connection.raw_connection.wait_for_notify(0.1) do |channel, pid, payload|
40
- job_notifications << payload if payload
41
- loop do
42
- payload = connection.raw_connection.notifies
43
- break unless @running && payload
44
- job_notifications << payload[:extra]
45
- end
46
- job_notifications.each do |n|
47
- op, id, worker_id, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
48
- next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0 || scheduled_at.to_f < @last_dispatch_at
49
- if scheduled_at.to_f <= Time.now.to_f
50
- @next_schedule_at = Time.now.to_f
51
- elsif scheduled_at.to_f < @next_schedule_at
52
- @next_schedule_at = scheduled_at.to_f
53
- end
54
- end
55
- end
56
- if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
57
- @executor.post { do_work }
58
- end
59
- if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
60
- @worker.touch
61
- timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
62
- end
63
- rescue Exception => ex
64
- # most likely error with database connection
65
- Skiplock.logger.error(ex)
66
- Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
67
- error = true
68
- t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
- while @running
70
- sleep(0.5)
71
- break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - t > 5
72
- end
73
- @last_exception = ex
74
- end
75
- sleep(0.2)
76
- end
77
- connection.exec_query('UNLISTEN *')
78
- @executor.shutdown
79
- @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
80
- end
81
- end
82
- end
83
-
84
- def shutdown
85
- @running = false
86
- end
87
-
88
- private
89
-
90
- def check_sync_errors
91
- # get performed jobs that could not sync with database
92
- Dir.glob('tmp/skiplock/*').each do |f|
93
- job_from_db = Job.find_by(id: File.basename(f), running: true)
94
- disposed = true
95
- if job_from_db
96
- job, ex = YAML.load_file(f) rescue nil
97
- disposed = job.dispose(ex, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries])
98
- end
99
- File.delete(f) if disposed
100
- end
101
- end
102
-
103
- def do_work
104
- while @running
105
- @last_dispatch_at = Time.now.to_f - 1 # 1 second allowance for time drift
106
- result = Job.dispatch(queues_order_query: @queues_order_query, worker_id: @worker.id, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries])
107
- next if result.is_a?(Job) && Time.now.to_f >= @next_schedule_at
108
- @next_schedule_at = result if result.is_a?(Float)
109
- break
110
- end
111
- rescue Exception => ex
112
- Skiplock.logger.error(ex)
113
- Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
114
- @last_exception = ex
115
- end
116
- end
117
- end