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 +4 -4
- data/README.md +13 -11
- data/bin/skiplock +2 -2
- data/lib/active_job/queue_adapters/skiplock_adapter.rb +1 -1
- data/lib/generators/skiplock/templates/migration.rb.erb +1 -1
- data/lib/skiplock/cron.rb +1 -0
- data/lib/skiplock/job.rb +49 -31
- data/lib/skiplock/manager.rb +73 -81
- data/lib/skiplock/version.rb +1 -1
- data/lib/skiplock/worker.rb +116 -0
- data/lib/skiplock.rb +2 -3
- metadata +2 -3
- data/lib/skiplock/dispatcher.rb +0 -117
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 55c98833e2792a216b6389d399e992aae9660f23ea076fcf852fb164cbc32d55
|
4
|
+
data.tar.gz: 43c8c2f90ee55ec423242042d1ab2986c0cb9f70fcc04db56aaf2c566bbb0382
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
71
|
-
- **
|
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, --
|
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
|
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
|
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', '--
|
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
|
-
|
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
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(
|
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
|
9
|
-
return
|
10
|
-
job =
|
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.
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
data/lib/skiplock/manager.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
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
|
-
|
58
|
+
private
|
31
59
|
|
32
60
|
def banner
|
33
|
-
title = "
|
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
|
65
|
+
@logger.info "ClassMethod extensions: #{@config[:extensions]}"
|
38
66
|
@logger.info " Purge completion: #{@config[:purge_completion]}"
|
39
|
-
@logger.info " Notification: #{@config[:notification]}
|
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 "
|
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
|
-
|
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(
|
64
|
-
|
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
|
-
|
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
|
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
|
-
@
|
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
|
-
|
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
|
-
|
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
|
-
|
110
|
-
|
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
|
data/lib/skiplock/version.rb
CHANGED
data/lib/skiplock/worker.rb
CHANGED
@@ -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, '
|
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 || []
|
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.
|
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-
|
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
|
data/lib/skiplock/dispatcher.rb
DELETED
@@ -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
|