skiplock 1.0.9 → 1.0.10
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 +41 -20
- data/bin/skiplock +6 -4
- data/lib/active_job/queue_adapters/skiplock_adapter.rb +1 -1
- data/lib/generators/skiplock/install_generator.rb +1 -1
- data/lib/generators/skiplock/templates/migration.rb.erb +5 -3
- data/lib/skiplock/cron.rb +2 -2
- data/lib/skiplock/dispatcher.rb +70 -98
- data/lib/skiplock/extension.rb +25 -0
- data/lib/skiplock/job.rb +31 -16
- data/lib/skiplock/manager.rb +98 -104
- data/lib/skiplock/version.rb +1 -1
- data/lib/skiplock/worker.rb +1 -0
- data/lib/skiplock.rb +17 -15
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 681b41b73f6ddf95620515f3d83454ba1f623d57740248bf84041829c225318d
|
4
|
+
data.tar.gz: 0433b2e635174ae052b67d7502e4b2477c54736f79ec0acf0ad369a1be976256
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6860b6cff70a8881277fd0ce7d2d76d211723e8c1974a1e2b70db7ea31b05268af6d38b68f2437cc9144d0d896751de205e21890219d3664b0f5ae128e1c6ab0
|
7
|
+
data.tar.gz: ddf11fbb1b030dc437254f87142127fd135bacbb84301d03fbcb14e3029bc05736e9c02b1d08b6be8f2d6b90e3fbfdf8eff568c63c8d8c9b451c730a1a125b39
|
data/README.md
CHANGED
@@ -48,43 +48,49 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
|
|
48
48
|
```
|
49
49
|
2. `Skiplock` configuration
|
50
50
|
```yaml
|
51
|
-
# config/skiplock.yml
|
51
|
+
# config/skiplock.yml (default settings)
|
52
52
|
---
|
53
|
-
|
53
|
+
extensions: false
|
54
|
+
logging: true
|
54
55
|
min_threads: 1
|
55
56
|
max_threads: 5
|
56
57
|
max_retries: 20
|
57
|
-
notification:
|
58
|
+
notification: none
|
58
59
|
purge_completion: true
|
59
60
|
queues:
|
60
61
|
default: 200
|
61
|
-
mailers:
|
62
|
+
mailers: 999
|
62
63
|
workers: 0
|
63
64
|
```
|
64
65
|
Available configuration options are:
|
65
|
-
- **
|
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
|
66
68
|
- **min_threads** (*integer*): sets minimum number of threads staying idle
|
67
69
|
- **max_threads** (*integer*): sets the maximum number of threads allowed to run jobs
|
68
|
-
- **max_retries** (*integer*): sets the maximum attempt a job will be retrying before it is marked expired
|
69
|
-
- **notification** (*enumeration*): sets the library to be used for notifying errors and exceptions (`auto, airbrake, bugsnag, exception_notification,
|
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
|
70
72
|
- **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)
|
71
73
|
- **queues** (*hash*): defines the set of queues with priorities; lower priority takes precedence
|
72
74
|
- **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**
|
73
|
-
|
74
|
-
#### Async mode
|
75
|
-
When **workers** is set to **0** then the jobs will be performed in the web server process using separate threads. If using multi-worker cluster mode web server like Puma, then it should be configured as below:
|
76
|
-
```ruby
|
77
|
-
# config/puma.rb
|
78
|
-
# ...
|
79
|
-
on_worker_fork do |worker_index|
|
80
|
-
Skiplock::Manager.shutdown if worker_index == 1
|
81
|
-
end
|
82
75
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
76
|
+
#### **Async mode**
|
77
|
+
When **workers** is set to **0** then the jobs will be performed in the web server process using separate threads. If using multi-worker cluster mode web server like Puma, then all the Puma workers will also be able to perform `Skiplock` jobs.
|
78
|
+
|
79
|
+
#### **Standalone mode**
|
80
|
+
`Skiplock` standalone mode can be launched by using the `skiplock` executable; command line options can be provided to override the `Skiplock` configuration file.
|
81
|
+
```
|
82
|
+
$ bundle exec skiplock -h
|
83
|
+
Usage: skiplock [options]
|
84
|
+
-e, --environment STRING Rails environment
|
85
|
+
-l, --logging STRING Possible values: true, false, timestamp
|
86
|
+
-s, --graceful-shutdown NUM Number of seconds to wait for graceful shutdown
|
87
|
+
-r, --max-retries NUM Number of maxixum retries
|
88
|
+
-t, --max-threads NUM Number of maximum threads
|
89
|
+
-T, --min-threads NUM Number of minimum threads
|
90
|
+
-w, --workers NUM Number of workers
|
91
|
+
-h, --help Show this message
|
87
92
|
```
|
93
|
+
|
88
94
|
## Usage
|
89
95
|
Inside the Rails application:
|
90
96
|
- queue your job
|
@@ -159,10 +165,25 @@ If the `retry_on` block is not defined, then the built-in retry system of `skipl
|
|
159
165
|
if ex.backtrace != previous.try(:backtrace)
|
160
166
|
# sends custom email on new exceptions only
|
161
167
|
# 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
|
162
169
|
end
|
163
170
|
end
|
164
171
|
# supports multiple on_error callbacks
|
165
172
|
```
|
173
|
+
## ClassMethod extension
|
174
|
+
`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`.
|
175
|
+
- Queue class method `generate_thumbnails` of class `Image` as background job to run as soon as possible
|
176
|
+
```ruby
|
177
|
+
Image.skiplock.generate_thumbnails(height: 100, ratio: true)
|
178
|
+
```
|
179
|
+
- Queue class method `cleanup` of class `Session` as background job on queue `maintenance` to run after 5 minutes
|
180
|
+
```ruby
|
181
|
+
Session.skiplock(wait: 5.minutes, queue: 'maintenance').cleanup
|
182
|
+
```
|
183
|
+
- Queue class method `charge` of class `Subscription` as background job to run tomorrow at noon
|
184
|
+
```ruby
|
185
|
+
Subscription.skiplock(wait_until: Date.tomorrow.noon).charge(amount: 100)
|
186
|
+
```
|
166
187
|
|
167
188
|
## Contributing
|
168
189
|
|
data/bin/skiplock
CHANGED
@@ -6,9 +6,10 @@ begin
|
|
6
6
|
opts.banner = "Usage: #{File.basename($0)} [options]"
|
7
7
|
opts.on('-e', '--environment STRING', String, 'Rails environment')
|
8
8
|
opts.on('-l', '--logging STRING', String, 'Possible values: true, false, timestamp')
|
9
|
-
opts.on('-
|
10
|
-
opts.on('-
|
11
|
-
opts.on('-
|
9
|
+
opts.on('-s', '--graceful-shutdown NUM', Integer, 'Number of seconds to wait for graceful shutdown')
|
10
|
+
opts.on('-r', '--max-retries NUM', Integer, 'Number of maxixum retries')
|
11
|
+
opts.on('-t', '--max-threads NUM', Integer, 'Number of maximum threads')
|
12
|
+
opts.on('-T', '--min-threads NUM', Integer, 'Number of minimum threads')
|
12
13
|
opts.on('-w', '--workers NUM', Integer, 'Number of workers')
|
13
14
|
opts.on_tail('-h', '--help', 'Show this message') do
|
14
15
|
exit
|
@@ -20,7 +21,8 @@ rescue Exception => e
|
|
20
21
|
puts op
|
21
22
|
exit
|
22
23
|
end
|
24
|
+
options.transform_keys! { |k| k.to_s.gsub('-', '_').to_sym }
|
23
25
|
env = options.delete(:environment)
|
24
26
|
ENV['RAILS_ENV'] = env if env
|
25
27
|
require File.expand_path("config/environment.rb")
|
26
|
-
Skiplock::Manager.
|
28
|
+
Skiplock::Manager.new(options.merge(standalone: true))
|
@@ -27,10 +27,11 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
|
|
27
27
|
t.timestamps null: false, default: -> { 'now()' }
|
28
28
|
end
|
29
29
|
create_table 'skiplock.workers', id: :uuid do |t|
|
30
|
-
t.integer :pid, null: false
|
31
|
-
t.integer :
|
30
|
+
t.integer :pid, null: false
|
31
|
+
t.integer :sid, null: false
|
32
32
|
t.integer :capacity, null: false
|
33
33
|
t.string :hostname, null: false, index: true
|
34
|
+
t.boolean :master, null: false, default: false, index: true
|
34
35
|
t.jsonb :data
|
35
36
|
t.timestamps null: false, index: true, default: -> { 'now()' }
|
36
37
|
end
|
@@ -74,7 +75,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
|
|
74
75
|
ELSE
|
75
76
|
record = NEW;
|
76
77
|
END IF;
|
77
|
-
PERFORM pg_notify('skiplock::workers', CONCAT(TG_OP,',',record.id::TEXT,',',record.hostname,',',record.capacity,',',record.pid,',',record.
|
78
|
+
PERFORM pg_notify('skiplock::workers', CONCAT(TG_OP,',',record.id::TEXT,',',record.hostname,',',record.master::TEXT,',',record.capacity,',',record.pid,',',record.sid,',',CAST(EXTRACT(EPOCH FROM record.created_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.updated_at) AS FLOAT)::TEXT));
|
78
79
|
RETURN NULL;
|
79
80
|
END;
|
80
81
|
$$ LANGUAGE plpgsql;
|
@@ -85,6 +86,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
|
|
85
86
|
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
87
|
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
88
|
execute "CREATE UNIQUE INDEX jobs_unique_cron_index ON skiplock.jobs (job_class) WHERE cron IS NOT NULL"
|
89
|
+
execute "CREATE UNIQUE INDEX workers_unique_master_index ON skiplock.workers(hostname) WHERE master = 't'"
|
88
90
|
end
|
89
91
|
|
90
92
|
def down
|
data/lib/skiplock/cron.rb
CHANGED
@@ -6,13 +6,13 @@ module Skiplock
|
|
6
6
|
ActiveJob::Base.descendants.each do |j|
|
7
7
|
next unless j.const_defined?('CRON')
|
8
8
|
cron = j.const_get('CRON')
|
9
|
-
job = Job.find_by('job_class = ? AND cron IS NOT NULL', j.name) || Job.new(job_class: j.name, cron: cron)
|
9
|
+
job = Job.find_by('job_class = ? AND cron IS NOT NULL', j.name) || Job.new(job_class: j.name, cron: cron, locale: I18n.locale, timezone: Time.zone.name)
|
10
10
|
time = self.next_schedule_at(cron)
|
11
11
|
if time
|
12
12
|
job.cron = cron
|
13
13
|
job.running = false
|
14
14
|
job.scheduled_at = Time.at(time)
|
15
|
-
job.save
|
15
|
+
job.save
|
16
16
|
cronjobs << j.name
|
17
17
|
end
|
18
18
|
end
|
data/lib/skiplock/dispatcher.rb
CHANGED
@@ -1,115 +1,88 @@
|
|
1
1
|
module Skiplock
|
2
2
|
class Dispatcher
|
3
|
-
def initialize(
|
4
|
-
@
|
5
|
-
@
|
6
|
-
@
|
7
|
-
|
8
|
-
@worker_pids = worker_pids + [ Process.pid ]
|
9
|
-
else
|
10
|
-
@worker_num = worker_num
|
11
|
-
end
|
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)
|
12
8
|
@last_dispatch_at = 0
|
13
9
|
@next_schedule_at = Time.now.to_f
|
14
|
-
@
|
10
|
+
Process.setproctitle("skiplock-#{@worker.master ? 'master[0]' : 'worker[' + @worker_num.to_s + ']'}") if @config[:standalone]
|
15
11
|
end
|
16
12
|
|
17
13
|
def run
|
14
|
+
@running = true
|
18
15
|
Thread.new do
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
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
|
38
37
|
end
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
if error
|
47
|
-
unless connection.active?
|
48
|
-
connection.reconnect!
|
49
|
-
sleep(0.5)
|
50
|
-
connection.exec_query('LISTEN "skiplock::jobs"')
|
51
|
-
connection.exec_query('LISTEN "skiplock::workers"') if @master
|
52
|
-
@next_schedule_at = Time.now.to_f
|
53
|
-
end
|
54
|
-
check_sync_errors
|
55
|
-
error = false
|
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]
|
56
45
|
end
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
end
|
65
|
-
notifications['skiplock::jobs'].each do |n|
|
66
|
-
op, id, worker_id, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
|
67
|
-
if @master
|
68
|
-
# TODO: report job status to action cable
|
69
|
-
end
|
70
|
-
next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0 || scheduled_at.to_f < @last_dispatch_at
|
71
|
-
if scheduled_at.to_f < Time.now.to_f
|
72
|
-
@next_schedule_at = Time.now.to_f
|
73
|
-
elsif scheduled_at.to_f < @next_schedule_at
|
74
|
-
@next_schedule_at = scheduled_at.to_f
|
75
|
-
end
|
76
|
-
end
|
77
|
-
if @master
|
78
|
-
# TODO: report worker status to action cable
|
79
|
-
notifications['skiplock::workers'].each do |n|
|
80
|
-
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
|
81
53
|
end
|
82
54
|
end
|
83
|
-
if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
|
84
|
-
@executor.post { do_work }
|
85
|
-
end
|
86
|
-
rescue Exception => ex
|
87
|
-
# most likely error with database connection
|
88
|
-
STDERR.puts ex.message
|
89
|
-
STDERR.puts ex.backtrace
|
90
|
-
Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
|
91
|
-
error = true
|
92
|
-
t = Time.now
|
93
|
-
while @running
|
94
|
-
sleep(0.5)
|
95
|
-
break if Time.now - t > 5
|
96
|
-
end
|
97
|
-
@last_exception = ex
|
98
55
|
end
|
99
|
-
|
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
|
100
74
|
end
|
101
|
-
|
102
|
-
@executor.shutdown
|
103
|
-
@executor.wait_for_termination if @wait
|
104
|
-
@worker.delete if @worker
|
75
|
+
sleep(0.2)
|
105
76
|
end
|
77
|
+
connection.exec_query('UNLISTEN *')
|
78
|
+
@executor.shutdown
|
79
|
+
@executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
|
106
80
|
end
|
107
81
|
end
|
108
82
|
end
|
109
83
|
|
110
|
-
def shutdown
|
84
|
+
def shutdown
|
111
85
|
@running = false
|
112
|
-
@wait = wait
|
113
86
|
end
|
114
87
|
|
115
88
|
private
|
@@ -121,7 +94,7 @@ module Skiplock
|
|
121
94
|
disposed = true
|
122
95
|
if job_from_db
|
123
96
|
job, ex = YAML.load_file(f) rescue nil
|
124
|
-
disposed = job.dispose(ex)
|
97
|
+
disposed = job.dispose(ex, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries])
|
125
98
|
end
|
126
99
|
File.delete(f) if disposed
|
127
100
|
end
|
@@ -129,15 +102,14 @@ module Skiplock
|
|
129
102
|
|
130
103
|
def do_work
|
131
104
|
while @running
|
132
|
-
@last_dispatch_at = Time.now.to_f - 1 # 1 second allowance for
|
133
|
-
result = Job.dispatch(queues_order_query: @queues_order_query, worker_id: @worker.id)
|
134
|
-
next if result.is_a?(Job)
|
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
|
135
108
|
@next_schedule_at = result if result.is_a?(Float)
|
136
109
|
break
|
137
110
|
end
|
138
111
|
rescue Exception => ex
|
139
|
-
|
140
|
-
STDERR.puts ex.backtrace
|
112
|
+
Skiplock.logger.error(ex)
|
141
113
|
Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
|
142
114
|
@last_exception = ex
|
143
115
|
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Skiplock
|
2
|
+
module Extension
|
3
|
+
class Proxy < BasicObject
|
4
|
+
def initialize(target, options = {})
|
5
|
+
@target = target
|
6
|
+
@options = options
|
7
|
+
end
|
8
|
+
|
9
|
+
def method_missing(name, *args)
|
10
|
+
ProxyJob.set(@options).perform_later(::YAML.dump([ @target, name, args ]))
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class ProxyJob < ActiveJob::Base
|
15
|
+
def perform(yml)
|
16
|
+
target, method_name, args = ::YAML.load(yml)
|
17
|
+
target.__send__(method_name, *args)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def skiplock(options = {})
|
22
|
+
Proxy.new(self, options)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/skiplock/job.rb
CHANGED
@@ -1,24 +1,34 @@
|
|
1
1
|
module Skiplock
|
2
2
|
class Job < ActiveRecord::Base
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
3
|
+
self.implicit_order_column = 'created_at'
|
4
|
+
|
5
|
+
def self.dispatch(queues_order_query: nil, worker_id: nil, purge_completion: true, max_retries: 20)
|
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
|
9
11
|
end
|
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
|
11
|
-
self.connection.exec_query('END')
|
12
12
|
job.data ||= {}
|
13
13
|
job.exception_executions ||= {}
|
14
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
15
|
job.executions = (job.executions || 0) + 1
|
16
|
+
Skiplock.logger.info "[Skiplock] Performing #{job.job_class} (#{job.id}) from queue '#{job.queue_name || 'default'}'..."
|
16
17
|
Thread.current[:skiplock_dispatch_job] = job
|
18
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
17
19
|
begin
|
18
20
|
ActiveJob::Base.execute(job_data)
|
19
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}'"
|
20
29
|
end
|
21
|
-
job.
|
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)
|
22
32
|
ensure
|
23
33
|
Thread.current[:skiplock_dispatch_job] = nil
|
24
34
|
end
|
@@ -35,22 +45,27 @@ module Skiplock
|
|
35
45
|
Thread.current[:skiplock_dispatch_job].scheduled_at = timestamp
|
36
46
|
Thread.current[:skiplock_dispatch_job]
|
37
47
|
else
|
38
|
-
|
48
|
+
serialize = activejob.serialize
|
49
|
+
Job.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'] }, 'scheduled_at' => timestamp))
|
39
50
|
end
|
40
51
|
end
|
41
52
|
|
42
|
-
def
|
53
|
+
def self.reset_retry_schedules
|
54
|
+
self.where('scheduled_at > NOW() AND executions IS NOT NULL AND expired_at IS NULL AND finished_at IS NULL').update_all(scheduled_at: nil, updated_at: Time.now)
|
55
|
+
end
|
56
|
+
|
57
|
+
def dispose(ex, purge_completion: true, max_retries: 20)
|
58
|
+
dup = self.dup
|
43
59
|
self.running = false
|
44
60
|
self.updated_at = (Time.now > self.updated_at ? Time.now : self.updated_at + 1)
|
45
61
|
if ex
|
46
62
|
self.exception_executions["[#{ex.class.name}]"] = (self.exception_executions["[#{ex.class.name}]"] || 0) + 1 unless self.exception_executions.key?('activejob_retry')
|
47
|
-
if self.executions >=
|
63
|
+
if self.executions >= max_retries || self.exception_executions.key?('activejob_retry')
|
48
64
|
self.expired_at = Time.now
|
49
|
-
self.save!
|
50
65
|
else
|
51
66
|
self.scheduled_at = Time.now + (5 * 2**self.executions)
|
52
|
-
self.save!
|
53
67
|
end
|
68
|
+
self.save!
|
54
69
|
Skiplock.on_errors.each { |p| p.call(ex) }
|
55
70
|
elsif self.exception_executions.try(:key?, 'activejob_retry')
|
56
71
|
self.save!
|
@@ -67,7 +82,7 @@ module Skiplock
|
|
67
82
|
else
|
68
83
|
self.delete
|
69
84
|
end
|
70
|
-
elsif
|
85
|
+
elsif purge_completion
|
71
86
|
self.delete
|
72
87
|
else
|
73
88
|
self.finished_at = Time.now
|
@@ -76,7 +91,7 @@ module Skiplock
|
|
76
91
|
end
|
77
92
|
self
|
78
93
|
rescue
|
79
|
-
File.write("tmp/skiplock/#{self.id}", [
|
94
|
+
File.write("tmp/skiplock/#{self.id}", [dup, ex].to_yaml)
|
80
95
|
nil
|
81
96
|
end
|
82
97
|
end
|
data/lib/skiplock/manager.rb
CHANGED
@@ -1,49 +1,83 @@
|
|
1
1
|
module Skiplock
|
2
2
|
class Manager
|
3
|
-
def
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
end
|
20
|
-
return unless standalone || restart || (caller.any?{|l| l =~ %r{/rack/}} && Settings['workers'] == 0)
|
21
|
-
if standalone
|
22
|
-
self.standalone
|
3
|
+
def initialize(**config)
|
4
|
+
@config = Skiplock::DEFAULT_CONFIG.dup
|
5
|
+
@config.merge!(YAML.load_file('config/skiplock.yml')) rescue nil
|
6
|
+
@config.symbolize_keys!
|
7
|
+
@config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
|
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
|
+
@config[:hostname] = `hostname -f`.strip
|
12
|
+
do_config
|
13
|
+
banner
|
14
|
+
cleanup_workers
|
15
|
+
create_worker
|
16
|
+
ActiveJob::Base.logger = nil
|
17
|
+
if @config[:standalone]
|
18
|
+
standalone
|
23
19
|
else
|
24
|
-
|
25
|
-
|
26
|
-
at_exit
|
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
|
26
|
+
end
|
27
27
|
end
|
28
|
-
ActiveJob::Base.logger = nil
|
29
28
|
end
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def banner
|
33
|
+
title = "[Skiplock] V#{Skiplock::VERSION} (Rails #{Rails::VERSION::STRING} | Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
|
34
|
+
@logger.info "-"*(title.length)
|
35
|
+
@logger.info title
|
36
|
+
@logger.info "-"*(title.length)
|
37
|
+
@logger.info "ClassMethod Extensions: #{@config[:extensions]}"
|
38
|
+
@logger.info " Purge completion: #{@config[:purge_completion]}"
|
39
|
+
@logger.info " Notification: #{@config[:notification]}#{(' (' + @notification + ')') if @config[:notification] == 'auto'}"
|
40
|
+
@logger.info " Max retries: #{@config[:max_retries]}"
|
41
|
+
@logger.info " Min threads: #{@config[:min_threads]}"
|
42
|
+
@logger.info " Max threads: #{@config[:max_threads]}"
|
43
|
+
@logger.info " Environment: #{Rails.env}"
|
44
|
+
@logger.info " Logging: #{@config[:logging]}"
|
45
|
+
@logger.info " Workers: #{@config[:workers]}"
|
46
|
+
@logger.info " Queues: #{@config[:queues].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if @config[:queues].is_a?(Hash)
|
47
|
+
@logger.info " PID: #{Process.pid}"
|
48
|
+
@logger.info "-"*(title.length)
|
49
|
+
end
|
50
|
+
|
51
|
+
def cleanup_workers
|
52
|
+
delete_ids = []
|
53
|
+
Worker.where(hostname: @config[:hostname]).each do |worker|
|
54
|
+
sid = Process.getsid(worker.pid) rescue nil
|
55
|
+
delete_ids << worker.id if worker.sid != sid || worker.updated_at < 30.minutes.ago
|
56
|
+
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
|
37
60
|
end
|
38
61
|
end
|
39
62
|
|
40
|
-
|
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])
|
65
|
+
rescue
|
66
|
+
@worker = Worker.create!(pid: pid, sid: sid, master: false, hostname: @config[:hostname], capacity: @config[:max_threads])
|
67
|
+
end
|
41
68
|
|
42
|
-
def
|
43
|
-
config =
|
44
|
-
|
45
|
-
|
46
|
-
@
|
69
|
+
def do_config
|
70
|
+
@config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
|
71
|
+
@config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
|
72
|
+
@config[:max_retries] = 20 if @config[:max_retries] > 20
|
73
|
+
@config[:max_retries] = 0 if @config[:max_retries] < 0
|
74
|
+
@config[:max_threads] = 1 if @config[:max_threads] < 1
|
75
|
+
@config[:max_threads] = 20 if @config[:max_threads] > 20
|
76
|
+
@config[:min_threads] = 0 if @config[:min_threads] < 0
|
77
|
+
@config[:workers] = 0 if @config[:workers] < 0
|
78
|
+
@config[:workers] = 1 if @config[:standalone] && @config[:workers] <= 0
|
79
|
+
@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]
|
47
81
|
if @notification == 'auto'
|
48
82
|
if defined?(Airbrake)
|
49
83
|
@notification = 'airbrake'
|
@@ -52,7 +86,7 @@ module Skiplock
|
|
52
86
|
elsif defined?(ExceptionNotifier)
|
53
87
|
@notification = 'exception_notification'
|
54
88
|
else
|
55
|
-
|
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'"
|
56
90
|
exit
|
57
91
|
end
|
58
92
|
end
|
@@ -73,92 +107,52 @@ module Skiplock
|
|
73
107
|
ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace)
|
74
108
|
end
|
75
109
|
end
|
76
|
-
|
77
|
-
|
78
|
-
|
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
|
119
|
+
end
|
120
|
+
Skiplock.on_errors.freeze unless Skiplock.on_errors.frozen?
|
79
121
|
end
|
80
122
|
|
81
|
-
def
|
82
|
-
if Settings['logging']
|
83
|
-
log_timestamp = (Settings['logging'].to_s == 'timestamp')
|
84
|
-
logfile = File.open('log/skiplock.log', 'a')
|
85
|
-
logfile.sync = true
|
86
|
-
$stdout = Demux.new(logfile, STDOUT, timestamp: log_timestamp)
|
87
|
-
errfile = File.open('log/skiplock.error.log', 'a')
|
88
|
-
errfile.sync = true
|
89
|
-
$stderr = Demux.new(errfile, STDERR, timestamp: log_timestamp)
|
90
|
-
logger = ActiveSupport::Logger.new($stdout)
|
91
|
-
logger.level = Rails.logger.level
|
92
|
-
Rails.logger.reopen('/dev/null')
|
93
|
-
Rails.logger.extend(ActiveSupport::Logger.broadcast(logger))
|
94
|
-
end
|
95
|
-
title = "Skiplock version: #{Skiplock::VERSION} (Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
|
96
|
-
puts "-"*(title.length)
|
97
|
-
puts title
|
98
|
-
puts "-"*(title.length)
|
99
|
-
puts "Purge completion: #{Settings['purge_completion']}"
|
100
|
-
puts " Notification: #{Settings['notification']}#{(' (' + @notification + ')') if Settings['notification'] == 'auto'}"
|
101
|
-
puts " Max retries: #{Settings['max_retries']}"
|
102
|
-
puts " Min threads: #{Settings['min_threads']}"
|
103
|
-
puts " Max threads: #{Settings['max_threads']}"
|
104
|
-
puts " Environment: #{Rails.env}"
|
105
|
-
puts " Logging: #{Settings['logging']}"
|
106
|
-
puts " Workers: #{Settings['workers']}"
|
107
|
-
puts " Queues: #{Settings['queues'].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if Settings['queues'].is_a?(Hash)
|
108
|
-
puts " PID: #{Process.pid}"
|
109
|
-
puts "-"*(title.length)
|
123
|
+
def standalone
|
110
124
|
parent_id = Process.pid
|
111
125
|
shutdown = false
|
112
126
|
Signal.trap("INT") { shutdown = true }
|
113
127
|
Signal.trap("TERM") { shutdown = true }
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
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)
|
118
133
|
thread = dispatcher.run
|
119
134
|
loop do
|
120
135
|
sleep 0.5
|
121
136
|
break if shutdown || Process.ppid != parent_id
|
122
137
|
end
|
123
|
-
dispatcher.shutdown
|
124
|
-
thread.join
|
138
|
+
dispatcher.shutdown
|
139
|
+
thread.join(@config[:graceful_shutdown])
|
140
|
+
worker.delete
|
125
141
|
exit
|
126
142
|
end
|
127
143
|
end
|
128
|
-
|
129
|
-
dispatcher = Dispatcher.new(worker_pids: worker_pids)
|
144
|
+
dispatcher = Dispatcher.new(worker: @worker, **@config)
|
130
145
|
thread = dispatcher.run
|
131
146
|
loop do
|
132
147
|
sleep 0.5
|
133
148
|
break if shutdown
|
134
149
|
end
|
150
|
+
@logger.info "[Skiplock] Terminating signal... Waiting for jobs to finish (up to #{@config[:graceful_shutdown]} seconds)..." if @config[:graceful_shutdown]
|
135
151
|
Process.waitall
|
136
|
-
dispatcher.shutdown
|
137
|
-
thread.join
|
138
|
-
|
139
|
-
|
140
|
-
class Demux
|
141
|
-
def initialize(*targets, timestamp: true)
|
142
|
-
@targets = targets
|
143
|
-
@timestamp = timestamp
|
144
|
-
end
|
145
|
-
|
146
|
-
def close
|
147
|
-
@targets.each(&:close)
|
148
|
-
end
|
149
|
-
|
150
|
-
def flush
|
151
|
-
@targets.each(&:flush)
|
152
|
-
end
|
153
|
-
|
154
|
-
def tty?
|
155
|
-
true
|
156
|
-
end
|
157
|
-
|
158
|
-
def write(*args)
|
159
|
-
args.prepend("[#{Time.now.utc}]: ") if @timestamp
|
160
|
-
@targets.each {|t| t.write(*args)}
|
161
|
-
end
|
152
|
+
dispatcher.shutdown
|
153
|
+
thread.join(@config[:graceful_shutdown])
|
154
|
+
@worker.delete
|
155
|
+
@logger.info "[Skiplock] Shutdown completed."
|
162
156
|
end
|
163
157
|
end
|
164
158
|
end
|
data/lib/skiplock/version.rb
CHANGED
data/lib/skiplock/worker.rb
CHANGED
data/lib/skiplock.rb
CHANGED
@@ -4,29 +4,31 @@ require 'active_record'
|
|
4
4
|
require 'skiplock/counter'
|
5
5
|
require 'skiplock/cron'
|
6
6
|
require 'skiplock/dispatcher'
|
7
|
+
require 'skiplock/extension'
|
7
8
|
require 'skiplock/job'
|
8
9
|
require 'skiplock/manager'
|
9
10
|
require 'skiplock/worker'
|
10
11
|
require 'skiplock/version'
|
11
12
|
|
12
13
|
module Skiplock
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
'mailers' => 100
|
23
|
-
},
|
24
|
-
'workers' => 0
|
25
|
-
}
|
26
|
-
mattr_reader :on_errors, default: []
|
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
|
15
|
+
|
16
|
+
def self.logger=(l)
|
17
|
+
@logger = l
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.logger
|
21
|
+
@logger
|
22
|
+
end
|
27
23
|
|
28
24
|
def self.on_error(&block)
|
29
|
-
|
25
|
+
@on_errors ||= []
|
26
|
+
@on_errors << block
|
27
|
+
block
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.on_errors
|
31
|
+
@on_errors || [].freeze
|
30
32
|
end
|
31
33
|
|
32
34
|
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.10
|
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-08-29 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activejob
|
@@ -85,6 +85,7 @@ files:
|
|
85
85
|
- lib/skiplock/counter.rb
|
86
86
|
- lib/skiplock/cron.rb
|
87
87
|
- lib/skiplock/dispatcher.rb
|
88
|
+
- lib/skiplock/extension.rb
|
88
89
|
- lib/skiplock/job.rb
|
89
90
|
- lib/skiplock/manager.rb
|
90
91
|
- lib/skiplock/version.rb
|