skiplock 1.0.17 → 1.0.22
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +16 -15
- data/bin/skiplock +1 -0
- data/lib/generators/skiplock/templates/migration.rb.erb +14 -12
- data/lib/skiplock/cron.rb +1 -1
- data/lib/skiplock/extension.rb +4 -1
- data/lib/skiplock/job.rb +35 -27
- data/lib/skiplock/manager.rb +41 -32
- data/lib/skiplock/patch.rb +1 -1
- data/lib/skiplock/version.rb +1 -1
- data/lib/skiplock/worker.rb +49 -42
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3b99466d7f1848f9dcd750a4dceb6a39d7124707e941c74b6c75f0b3a57944ff
|
4
|
+
data.tar.gz: 3f3ed1c9327d41e675d5c170590b52fe32f1facc5b57667dba513e5324e6ee20
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: '0129296507b0479ba28a6e4e0bce810340f0ff2559004da1385cf02b6d6023f7be5cb1543f3fb24cebdeb579d6be6ed1e5d7d72d43245de5374926607b23d7df'
|
7
|
+
data.tar.gz: 163faf212d06b6d7f8045eb347ccab77de5b16bfe77fe8780a5d73176ce3c5804cc25cbca8f962d92bc5dfccc1ada223d03fc0b443d0df48125a12bf4ec7ddeb
|
data/README.md
CHANGED
@@ -106,7 +106,7 @@ Inside the Rails application:
|
|
106
106
|
MyJob.set(wait_until: Day.tomorrow.noon).perform_later(1,2,3)
|
107
107
|
```
|
108
108
|
- Skiplock supports custom options which override the global `Skiplock` configuration options for specified jobs
|
109
|
-
- **purge** (*boolean*): whether to remove this job after it has
|
109
|
+
- **purge** (*boolean*): whether to remove this job after it has completed successfully
|
110
110
|
- **max_retries** (*integer*): set maximum retry attempt for this job
|
111
111
|
```ruby
|
112
112
|
MyJob.set(purge: false, max_retries: 5).perform_later(1,2,3)
|
@@ -119,7 +119,8 @@ Outside the Rails application:
|
|
119
119
|
- with scheduling, priority, queue, arguments and custom options
|
120
120
|
```sql
|
121
121
|
INSERT INTO skiplock.jobs(job_class, queue_name, priority, scheduled_at, data)
|
122
|
-
VALUES ('MyJob', 'my_queue', 10, NOW() + INTERVAL '5 min',
|
122
|
+
VALUES ('MyJob', 'my_queue', 10, NOW() + INTERVAL '5 min',
|
123
|
+
'{"arguments":[1,2,3],"options":{"purge":false,"max_retries":5}}');
|
123
124
|
```
|
124
125
|
## Queue priority vs Job priority
|
125
126
|
*Why do queues use priorities when jobs already have priorities?*
|
@@ -171,22 +172,22 @@ If the `retry_on` block is not defined, then the built-in retry system of `Skipl
|
|
171
172
|
`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:
|
172
173
|
```ruby
|
173
174
|
# config/initializers/skiplock.rb
|
174
|
-
Skiplock.on_error do |ex
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
# NOTE: exceptions generated from Job executions will not provide 'previous' exceptions
|
179
|
-
sms = Aws::SNS::Client.new(region: 'us-west-2', access_key_id: Rails.application.credentials[:aws][:access_key_id], secret_access_key: Rails.application.credentials[:aws][:secret_access_key])
|
180
|
-
sms.publish({ phone_number: '+122233334444', message: "Exception: #{ex.message}"[0..130] })
|
181
|
-
end
|
175
|
+
Skiplock.on_error do |ex|
|
176
|
+
# sends text message using Amazon SNS
|
177
|
+
sms = Aws::SNS::Client.new(region: 'us-west-2', access_key_id: Rails.application.credentials[:aws][:access_key_id], secret_access_key: Rails.application.credentials[:aws][:secret_access_key])
|
178
|
+
sms.publish(phone_number: '+12223334444', message: "Exception: #{ex.message}"[0..130])
|
182
179
|
end
|
183
180
|
# supports multiple 'on_error' event callbacks
|
184
181
|
```
|
185
182
|
## ClassMethod extension
|
186
|
-
`Skiplock` can add extension to allow class methods to be performed as a background job; it is disabled in the default configuration. To enable globally for all classes and modules, edit the `config/skiplock.yml` configuration file and change `extensions` to `true`; this can expose remote execution if the `skiplock.jobs` database table is not secured properly.
|
187
|
-
|
183
|
+
`Skiplock` can add extension to allow class methods to be performed as a background job; it is disabled in the default configuration. To enable globally for all classes and modules, edit the `config/skiplock.yml` configuration file and change `extensions` to `true`; this can expose remote code execution if the `skiplock.jobs` database table is not secured properly.
|
184
|
+
|
185
|
+
To enable extension for specific classes and modules only then set the configuration to an array of names of the classes and modules eg. `['MyClass', 'MyModule']`
|
186
|
+
- An example of remote code execution if the extension is enabled globally (ie: configuration is set to `true`) and attacker can insert `skiplock.jobs`
|
188
187
|
```sql
|
189
|
-
INSERT INTO skiplock.jobs(job_class, data)
|
188
|
+
INSERT INTO skiplock.jobs(job_class, data)
|
189
|
+
VALUES ('Skiplock::Extension::ProxyJob',
|
190
|
+
'{"arguments":["---\n- !ruby/module ''Kernel''\n- :system\n- - rm -rf /tmp/*\n"]}');
|
190
191
|
```
|
191
192
|
- Queue class method `generate_thumbnails` of class `Image` as background job to run as soon as possible
|
192
193
|
```ruby
|
@@ -196,9 +197,9 @@ If the `retry_on` block is not defined, then the built-in retry system of `Skipl
|
|
196
197
|
```ruby
|
197
198
|
Session.skiplock(wait: 5.minutes, queue: 'maintenance').cleanup
|
198
199
|
```
|
199
|
-
- Queue class method `charge` of class `Subscription` as background job to run tomorrow at noon
|
200
|
+
- Queue class method `charge` of class `Subscription` as background job to run tomorrow at noon without purging
|
200
201
|
```ruby
|
201
|
-
Subscription.skiplock(wait_until: Date.tomorrow.noon).charge(amount: 100)
|
202
|
+
Subscription.skiplock(purge: false, wait_until: Date.tomorrow.noon).charge(amount: 100)
|
202
203
|
```
|
203
204
|
|
204
205
|
## Fault tolerant
|
data/bin/skiplock
CHANGED
@@ -7,6 +7,7 @@ begin
|
|
7
7
|
opts.banner = "Usage: #{File.basename($0)} [options]"
|
8
8
|
opts.on('-e', '--environment STRING', String, 'Rails environment')
|
9
9
|
opts.on('-l', '--logfile STRING', String, 'Log filename')
|
10
|
+
opts.on('-L', '--loglevel STRING', String, 'Log level (debug, info, warn, error, fatal, unknown)')
|
10
11
|
opts.on('-s', '--graceful-shutdown NUM', Integer, 'Number of seconds to wait for graceful shutdown')
|
11
12
|
opts.on('-r', '--max-retries NUM', Integer, 'Number of maxixum retries')
|
12
13
|
opts.on('-t', '--max-threads NUM', Integer, 'Number of maximum threads')
|
@@ -46,18 +46,20 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
|
|
46
46
|
IF (record.running = TRUE) THEN
|
47
47
|
INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
|
48
48
|
END IF;
|
49
|
-
ELSIF (
|
50
|
-
IF (record.
|
51
|
-
|
52
|
-
|
53
|
-
|
49
|
+
ELSIF (TG_OP = 'UPDATE') THEN
|
50
|
+
IF (OLD.running = FALSE AND record.running = TRUE) THEN
|
51
|
+
IF (record.executions > 0) THEN
|
52
|
+
INSERT INTO skiplock.counters (day,retries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET retries = skiplock.counters.retries + 1;
|
53
|
+
ELSE
|
54
|
+
INSERT INTO skiplock.counters (day,dispatches) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET dispatches = skiplock.counters.dispatches + 1;
|
55
|
+
END IF;
|
56
|
+
ELSIF (OLD.finished_at IS NULL AND record.finished_at IS NOT NULL) THEN
|
57
|
+
INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
|
58
|
+
ELSIF (OLD.running = TRUE AND record.running = FALSE AND record.expired_at IS NOT NULL) THEN
|
59
|
+
INSERT INTO skiplock.counters (day,expiries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET expiries = skiplock.counters.expiries + 1;
|
60
|
+
ELSIF (OLD.running = TRUE AND record.running = FALSE AND record.expired_at IS NULL AND record.finished_at IS NULL) THEN
|
61
|
+
INSERT INTO skiplock.counters (day,failures) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET failures = skiplock.counters.failures + 1;
|
54
62
|
END IF;
|
55
|
-
ELSIF (record.finished_at IS NOT NULL) THEN
|
56
|
-
INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
|
57
|
-
ELSIF (record.expired_at IS NOT NULL) THEN
|
58
|
-
INSERT INTO skiplock.counters (day,expiries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET expiries = skiplock.counters.expiries + 1;
|
59
|
-
ELSIF (TG_OP = 'UPDATE' AND OLD.running = TRUE AND NEW.running = FALSE) THEN
|
60
|
-
INSERT INTO skiplock.counters (day,failures) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET failures = skiplock.counters.failures + 1;
|
61
63
|
END IF;
|
62
64
|
PERFORM pg_notify('skiplock::jobs', CONCAT(TG_OP,',',record.id::TEXT,',',record.worker_id::TEXT,',',record.job_class,',',record.queue_name,',',record.running::TEXT,',',CAST(EXTRACT(EPOCH FROM record.expired_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.finished_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM CASE WHEN record.scheduled_at IS NULL THEN record.updated_at ELSE record.scheduled_at END) AS FLOAT)::TEXT));
|
63
65
|
RETURN NULL;
|
@@ -85,7 +87,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
|
|
85
87
|
execute "CREATE INDEX jobs_retry_index ON skiplock.jobs(scheduled_at) WHERE running = FALSE AND executions IS NOT NULL AND expired_at IS NULL AND finished_at IS NULL"
|
86
88
|
execute "CREATE INDEX jobs_cron_index ON skiplock.jobs(scheduled_at ASC NULLS FIRST, priority ASC NULLS LAST, created_at ASC) WHERE cron IS NOT NULL AND finished_at IS NULL"
|
87
89
|
execute "CREATE UNIQUE INDEX jobs_unique_cron_index ON skiplock.jobs (job_class) WHERE cron IS NOT NULL"
|
88
|
-
execute "CREATE UNIQUE INDEX workers_unique_master_index ON skiplock.workers(hostname) WHERE master =
|
90
|
+
execute "CREATE UNIQUE INDEX workers_unique_master_index ON skiplock.workers(hostname) WHERE master = TRUE"
|
89
91
|
end
|
90
92
|
|
91
93
|
def down
|
data/lib/skiplock/cron.rb
CHANGED
@@ -11,7 +11,7 @@ module Skiplock
|
|
11
11
|
if time
|
12
12
|
job.cron = cron
|
13
13
|
job.running = false
|
14
|
-
job.scheduled_at = Time.at(time)
|
14
|
+
job.scheduled_at = Time.at(time) unless job.try(:executions).to_i > 0 # do not update schedule of retrying cron jobs
|
15
15
|
job.save
|
16
16
|
cronjobs << j.name
|
17
17
|
end
|
data/lib/skiplock/extension.rb
CHANGED
@@ -11,9 +11,12 @@ module Skiplock
|
|
11
11
|
end
|
12
12
|
end
|
13
13
|
|
14
|
+
class ProxyError < StandardError; end
|
15
|
+
|
14
16
|
class ProxyJob < ActiveJob::Base
|
15
17
|
def perform(yml)
|
16
|
-
target, method_name, args = ::YAML.load(yml)
|
18
|
+
target, method_name, args = ::YAML.load(yml) rescue nil
|
19
|
+
raise ProxyError, "Skiplock extension is not allowed for:\n#{yml}" unless target.respond_to?(:skiplock)
|
17
20
|
target.__send__(method_name, *args)
|
18
21
|
end
|
19
22
|
end
|
data/lib/skiplock/job.rb
CHANGED
@@ -1,7 +1,10 @@
|
|
1
1
|
module Skiplock
|
2
2
|
class Job < ActiveRecord::Base
|
3
|
-
self.implicit_order_column = '
|
4
|
-
|
3
|
+
self.implicit_order_column = 'updated_at'
|
4
|
+
attribute :activejob_error
|
5
|
+
attribute :exception
|
6
|
+
attribute :max_retries, :integer
|
7
|
+
attribute :purge, :boolean
|
5
8
|
belongs_to :worker, inverse_of: :jobs, required: false
|
6
9
|
|
7
10
|
def self.dispatch(purge_completion: true, max_retries: 20)
|
@@ -19,27 +22,28 @@ module Skiplock
|
|
19
22
|
end
|
20
23
|
|
21
24
|
def self.enqueue_at(activejob, timestamp)
|
25
|
+
options = activejob.instance_variable_get('@skiplock_options') || {}
|
22
26
|
timestamp = Time.at(timestamp) if timestamp
|
23
27
|
if Thread.current[:skiplock_job].try(:id) == activejob.job_id
|
24
|
-
Thread.current[:skiplock_job].
|
28
|
+
Thread.current[:skiplock_job].activejob_error = options[:error]
|
29
|
+
Thread.current[:skiplock_job].data['activejob_error'] = true
|
25
30
|
Thread.current[:skiplock_job].executions = activejob.executions
|
26
31
|
Thread.current[:skiplock_job].exception_executions = activejob.exception_executions
|
27
32
|
Thread.current[:skiplock_job].scheduled_at = timestamp
|
28
33
|
Thread.current[:skiplock_job]
|
29
34
|
else
|
30
|
-
options = activejob.instance_variable_get('@skiplock_options') || {}
|
31
35
|
serialize = activejob.serialize
|
32
36
|
self.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'], 'options' => options }, 'scheduled_at' => timestamp))
|
33
37
|
end
|
34
38
|
end
|
35
39
|
|
36
|
-
# resynchronize jobs that could not commit to database and
|
40
|
+
# resynchronize jobs that could not commit to database and reset any abandoned jobs for retry
|
37
41
|
def self.flush
|
38
42
|
Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
|
39
43
|
Dir.glob('tmp/skiplock/*').each do |f|
|
40
44
|
disposed = true
|
41
45
|
if self.exists?(id: File.basename(f), running: true)
|
42
|
-
job =
|
46
|
+
job = YAML.load_file(f) rescue nil
|
43
47
|
disposed = job.dispose if job.is_a?(Skiplock::Job)
|
44
48
|
end
|
45
49
|
(File.delete(f) rescue nil) if disposed
|
@@ -53,15 +57,15 @@ module Skiplock
|
|
53
57
|
end
|
54
58
|
|
55
59
|
def dispose
|
56
|
-
return unless
|
57
|
-
|
60
|
+
return unless self.max_retries
|
61
|
+
yaml = self.to_yaml
|
58
62
|
purging = false
|
59
63
|
self.running = false
|
60
64
|
self.worker_id = nil
|
61
65
|
self.updated_at = Time.now > self.updated_at ? Time.now : self.updated_at + 1 # in case of clock drifting
|
62
|
-
if
|
63
|
-
self.exception_executions["[#{
|
64
|
-
if (self.executions.to_i >=
|
66
|
+
if self.exception
|
67
|
+
self.exception_executions["[#{self.exception.class.name}]"] = self.exception_executions["[#{self.exception.class.name}]"].to_i + 1 unless self.data.key?('activejob_error')
|
68
|
+
if (self.executions.to_i >= self.max_retries + 1) || self.data.key?('activejob_error') || self.exception.is_a?(Skiplock::Extension::ProxyError)
|
65
69
|
self.expired_at = Time.now
|
66
70
|
else
|
67
71
|
self.scheduled_at = Time.now + (5 * 2**self.executions.to_i)
|
@@ -71,25 +75,27 @@ module Skiplock
|
|
71
75
|
self.data['cron'] ||= {}
|
72
76
|
self.data['cron']['executions'] = self.data['cron']['executions'].to_i + 1
|
73
77
|
self.data['cron']['last_finished_at'] = self.finished_at.utc.to_s
|
78
|
+
self.data['cron']['last_result'] = self.data['result']
|
74
79
|
next_cron_at = Cron.next_schedule_at(self.cron)
|
75
80
|
if next_cron_at
|
76
81
|
# update job to record completions counter before resetting finished_at to nil
|
77
|
-
self.update_columns(self.attributes.slice(*self.changes.keys))
|
82
|
+
self.update_columns(self.attributes.slice(*(self.changes.keys & self.class.column_names)))
|
78
83
|
self.finished_at = nil
|
79
84
|
self.executions = nil
|
80
85
|
self.exception_executions = nil
|
86
|
+
self.data.delete('result')
|
81
87
|
self.scheduled_at = Time.at(next_cron_at)
|
82
88
|
else
|
83
89
|
Skiplock.logger.error("[Skiplock] ERROR: Invalid CRON '#{self.cron}' for Job #{self.job_class}") if Skiplock.logger
|
84
90
|
purging = true
|
85
91
|
end
|
86
|
-
elsif
|
92
|
+
elsif self.purge == true
|
87
93
|
purging = true
|
88
94
|
end
|
89
95
|
end
|
90
|
-
purging ? self.delete : self.update_columns(self.attributes.slice(*self.changes.keys))
|
96
|
+
purging ? self.delete : self.update_columns(self.attributes.slice(*(self.changes.keys & self.class.column_names)))
|
91
97
|
rescue Exception => e
|
92
|
-
File.
|
98
|
+
File.write("tmp/skiplock/#{self.id}", yaml) rescue nil
|
93
99
|
if Skiplock.logger
|
94
100
|
Skiplock.logger.error(e.to_s)
|
95
101
|
Skiplock.logger.error(e.backtrace.join("\n"))
|
@@ -100,14 +106,15 @@ module Skiplock
|
|
100
106
|
|
101
107
|
def execute(purge_completion: true, max_retries: 20)
|
102
108
|
raise 'Job has already been completed' if self.finished_at
|
109
|
+
self.update_columns(running: true, updated_at: Time.now) unless self.running
|
103
110
|
Skiplock.logger.info("[Skiplock] Performing #{self.job_class} (#{self.id}) from queue '#{self.queue_name || 'default'}'...") if Skiplock.logger
|
104
111
|
self.data ||= {}
|
105
112
|
self.data.delete('result')
|
106
113
|
self.exception_executions ||= {}
|
107
|
-
self.
|
108
|
-
|
109
|
-
|
110
|
-
|
114
|
+
self.activejob_error = nil
|
115
|
+
self.max_retries = (self.data['options'].key?('max_retries') ? self.data['options']['max_retries'].to_i : max_retries) rescue max_retries
|
116
|
+
self.max_retries = 20 if self.max_retries < 0 || self.max_retries > 20
|
117
|
+
self.purge = (self.data['options'].key?('purge') ? self.data['options']['purge'] : purge_completion) rescue purge_completion
|
111
118
|
job_data = self.attributes.slice('job_class', 'queue_name', 'locale', 'timezone', 'priority', 'executions', 'exception_executions').merge('job_id' => self.id, 'enqueued_at' => self.updated_at, 'arguments' => (self.data['arguments'] || []))
|
112
119
|
self.executions = self.executions.to_i + 1
|
113
120
|
Thread.current[:skiplock_job] = self
|
@@ -115,15 +122,15 @@ module Skiplock
|
|
115
122
|
begin
|
116
123
|
self.data['result'] = ActiveJob::Base.execute(job_data)
|
117
124
|
rescue Exception => ex
|
118
|
-
|
119
|
-
Skiplock.on_errors.each { |p| p.call(
|
125
|
+
self.exception = ex
|
126
|
+
Skiplock.on_errors.each { |p| p.call(ex) }
|
120
127
|
end
|
121
128
|
if Skiplock.logger
|
122
|
-
if
|
123
|
-
Skiplock.logger.error("[Skiplock] Job #{self.job_class} (#{self.id}) was interrupted by an exception#{ ' (rescued and retried by ActiveJob)' if self.
|
124
|
-
if
|
125
|
-
Skiplock.logger.error(
|
126
|
-
Skiplock.logger.error(
|
129
|
+
if self.exception || self.activejob_error
|
130
|
+
Skiplock.logger.error("[Skiplock] Job #{self.job_class} (#{self.id}) was interrupted by an exception#{ ' (rescued and retried by ActiveJob)' if self.activejob_error }")
|
131
|
+
if self.exception
|
132
|
+
Skiplock.logger.error(self.exception.to_s)
|
133
|
+
Skiplock.logger.error(self.exception.backtrace.join("\n"))
|
127
134
|
end
|
128
135
|
else
|
129
136
|
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
@@ -135,8 +142,9 @@ module Skiplock
|
|
135
142
|
Skiplock.logger.info "[Skiplock] Performed #{job_name} (#{self.id}) from queue '#{self.queue_name || 'default'}' in #{end_time - start_time} seconds"
|
136
143
|
end
|
137
144
|
end
|
145
|
+
self.exception || self.activejob_error || self.data['result']
|
138
146
|
ensure
|
139
|
-
self.finished_at ||= Time.now if self.data.key?('result') && !self.
|
147
|
+
self.finished_at ||= Time.now if self.data.key?('result') && !self.activejob_error
|
140
148
|
self.dispose
|
141
149
|
end
|
142
150
|
end
|
data/lib/skiplock/manager.rb
CHANGED
@@ -1,20 +1,25 @@
|
|
1
1
|
module Skiplock
|
2
2
|
class Manager
|
3
|
-
def initialize
|
3
|
+
def initialize
|
4
4
|
@config = Skiplock::DEFAULT_CONFIG.dup
|
5
5
|
@config.merge!(YAML.load_file('config/skiplock.yml')) rescue nil
|
6
6
|
@config.symbolize_keys!
|
7
|
-
|
8
|
-
@config
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
if (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0)
|
13
|
-
Worker.cleanup(@hostname)
|
14
|
-
@worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
|
15
|
-
@worker.start(**@config)
|
16
|
-
at_exit { @worker.shutdown }
|
7
|
+
Rails.application.eager_load! if Rails.env.development?
|
8
|
+
if @config[:extensions] == true
|
9
|
+
Module.__send__(:include, Skiplock::Extension)
|
10
|
+
elsif @config[:extensions].is_a?(Array)
|
11
|
+
@config[:extensions].each { |n| n.constantize.__send__(:extend, Skiplock::Extension) if n.safe_constantize }
|
17
12
|
end
|
13
|
+
async if (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0)
|
14
|
+
end
|
15
|
+
|
16
|
+
def async
|
17
|
+
setup_logger
|
18
|
+
configure
|
19
|
+
Worker.cleanup(@hostname)
|
20
|
+
@worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
|
21
|
+
@worker.start(**@config)
|
22
|
+
at_exit { @worker.shutdown }
|
18
23
|
rescue Exception => ex
|
19
24
|
@logger.error(ex.to_s)
|
20
25
|
@logger.error(ex.backtrace.join("\n"))
|
@@ -22,18 +27,19 @@ module Skiplock
|
|
22
27
|
|
23
28
|
def standalone(**options)
|
24
29
|
@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
|
28
30
|
@config[:standalone] = true
|
31
|
+
@config[:workers] = 1 if @config[:workers] <= 0
|
32
|
+
setup_logger
|
33
|
+
configure
|
29
34
|
banner
|
30
|
-
Worker.cleanup(@hostname)
|
31
|
-
@worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
|
32
35
|
@parent_id = Process.pid
|
33
36
|
@shutdown = false
|
34
37
|
Signal.trap('INT') { @shutdown = true }
|
35
38
|
Signal.trap('TERM') { @shutdown = true }
|
36
39
|
Signal.trap('HUP') { setup_logger }
|
40
|
+
Worker.cleanup(@hostname)
|
41
|
+
@worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
|
42
|
+
ActiveRecord::Base.connection.disconnect! if @config[:workers] > 1
|
37
43
|
(@config[:workers] - 1).times do |n|
|
38
44
|
fork do
|
39
45
|
sleep 1
|
@@ -46,6 +52,7 @@ module Skiplock
|
|
46
52
|
worker.shutdown
|
47
53
|
end
|
48
54
|
end
|
55
|
+
ActiveRecord::Base.establish_connection if @config[:workers] > 1
|
49
56
|
@worker.start(**@config)
|
50
57
|
loop do
|
51
58
|
sleep 0.5
|
@@ -54,7 +61,9 @@ module Skiplock
|
|
54
61
|
@logger.info "[Skiplock] Terminating signal... Waiting for jobs to finish (up to #{@config[:graceful_shutdown]} seconds)..." if @config[:graceful_shutdown]
|
55
62
|
Process.waitall
|
56
63
|
@worker.shutdown
|
57
|
-
|
64
|
+
rescue Exception => ex
|
65
|
+
@logger.error(ex.to_s)
|
66
|
+
@logger.error(ex.backtrace.join("\n"))
|
58
67
|
end
|
59
68
|
|
60
69
|
private
|
@@ -81,6 +90,8 @@ module Skiplock
|
|
81
90
|
end
|
82
91
|
|
83
92
|
def configure
|
93
|
+
@hostname = Socket.gethostname
|
94
|
+
@config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
|
84
95
|
@config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
|
85
96
|
@config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
|
86
97
|
@config[:max_retries] = 20 if @config[:max_retries] > 20
|
@@ -104,40 +115,38 @@ module Skiplock
|
|
104
115
|
case @config[:notification]
|
105
116
|
when 'airbrake'
|
106
117
|
raise 'airbrake gem not found' unless defined?(Airbrake)
|
107
|
-
Skiplock.on_error do |ex
|
108
|
-
Airbrake.notify_sync(ex)
|
118
|
+
Skiplock.on_error do |ex|
|
119
|
+
Airbrake.notify_sync(ex)
|
109
120
|
end
|
110
121
|
when 'bugsnag'
|
111
122
|
raise 'bugsnag gem not found' unless defined?(Bugsnag)
|
112
|
-
Skiplock.on_error do |ex
|
113
|
-
Bugsnag.notify(ex)
|
123
|
+
Skiplock.on_error do |ex|
|
124
|
+
Bugsnag.notify(ex)
|
114
125
|
end
|
115
126
|
when 'exception_notification'
|
116
127
|
raise 'exception_notification gem not found' unless defined?(ExceptionNotifier)
|
117
|
-
Skiplock.on_error do |ex
|
118
|
-
ExceptionNotifier.notify_exception(ex)
|
128
|
+
Skiplock.on_error do |ex|
|
129
|
+
ExceptionNotifier.notify_exception(ex)
|
119
130
|
end
|
120
131
|
else
|
121
132
|
@config[:notification] = 'custom'
|
122
133
|
end
|
123
|
-
|
124
|
-
if @config[:extensions] == true
|
125
|
-
Module.__send__(:include, Skiplock::Extension)
|
126
|
-
elsif @config[:extensions].is_a?(Array)
|
127
|
-
@config[:extensions].each { |n| n.constantize.__send__(:extend, Skiplock::Extension) if n.safe_constantize }
|
128
|
-
end
|
129
|
-
Skiplock.on_errors.freeze unless Skiplock.on_errors.frozen?
|
134
|
+
Skiplock.on_errors.freeze
|
130
135
|
end
|
131
136
|
|
132
137
|
def setup_logger
|
133
|
-
@config[:loglevel] = 'info' unless ['debug','info','warn','error','fatal','unknown'].include?(@config[:loglevel].to_s
|
138
|
+
@config[:loglevel] = 'info' unless ['debug','info','warn','error','fatal','unknown'].include?(@config[:loglevel].to_s)
|
134
139
|
@logger = ActiveSupport::Logger.new(STDOUT)
|
135
|
-
@logger.level = @config[:loglevel].
|
140
|
+
@logger.level = @config[:loglevel].to_sym
|
136
141
|
Skiplock.logger = @logger
|
137
142
|
if @config[:logfile].to_s.length > 0
|
138
143
|
@logger.extend(ActiveSupport::Logger.broadcast(::Logger.new(File.join(Rails.root, 'log', @config[:logfile].to_s), 'daily')))
|
139
144
|
ActiveJob::Base.logger = nil
|
140
145
|
end
|
146
|
+
if @config[:standalone]
|
147
|
+
Rails.logger.reopen('/dev/null') rescue Rails.logger.reopen('NUL') # supports Windows NUL device
|
148
|
+
Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
|
149
|
+
end
|
141
150
|
rescue Exception => ex
|
142
151
|
@logger.error "Exception with logger: #{ex.to_s}"
|
143
152
|
@logger.error ex.backtrace.join("\n")
|
data/lib/skiplock/patch.rb
CHANGED
data/lib/skiplock/version.rb
CHANGED
data/lib/skiplock/worker.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
module Skiplock
|
2
2
|
class Worker < ActiveRecord::Base
|
3
|
-
self.implicit_order_column = '
|
3
|
+
self.implicit_order_column = 'updated_at'
|
4
4
|
has_many :jobs, inverse_of: :worker
|
5
5
|
|
6
6
|
def self.cleanup(hostname = nil)
|
@@ -18,74 +18,77 @@ module Skiplock
|
|
18
18
|
self.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: hostname, capacity: capacity)
|
19
19
|
end
|
20
20
|
|
21
|
-
def start(worker_num: 0, **config)
|
22
|
-
if self.master
|
23
|
-
Job.flush
|
24
|
-
Cron.setup
|
25
|
-
end
|
26
|
-
@config = config
|
27
|
-
@queues_order_query = @config[:queues].map { |q,v| "WHEN queue_name = '#{q}' THEN #{v}" }.join(' ') if @config[:queues].is_a?(Hash) && @config[:queues].count > 0
|
28
|
-
@running = true
|
29
|
-
@executor = Concurrent::ThreadPoolExecutor.new(min_threads: @config[:min_threads] + 1, max_threads: @config[:max_threads] + 1, max_queue: @config[:max_threads], idletime: 60, auto_terminate: true, fallback_policy: :discard)
|
30
|
-
@executor.post { run }
|
31
|
-
Process.setproctitle("skiplock-#{self.master ? 'master[0]' : 'worker[' + worker_num.to_s + ']'}") if @config[:standalone]
|
32
|
-
end
|
33
|
-
|
34
21
|
def shutdown
|
35
22
|
@running = false
|
36
23
|
@executor.shutdown
|
37
24
|
@executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
|
38
25
|
self.delete
|
26
|
+
Skiplock.logger.info "[Skiplock] Shutdown of #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2} (PID: #{self.pid}) was completed."
|
39
27
|
end
|
40
28
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
@
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
job
|
50
|
-
end
|
29
|
+
def start(worker_num: 0, **config)
|
30
|
+
@num = worker_num
|
31
|
+
@config = config
|
32
|
+
@queues_order_query = @config[:queues].map { |q,v| "WHEN queue_name = '#{q}' THEN #{v}" }.join(' ') if @config[:queues].is_a?(Hash) && @config[:queues].count > 0
|
33
|
+
@running = true
|
34
|
+
@executor = Concurrent::ThreadPoolExecutor.new(min_threads: @config[:min_threads] + 1, max_threads: @config[:max_threads] + 1, max_queue: @config[:max_threads] + 1, idletime: 60, auto_terminate: true, fallback_policy: :discard)
|
35
|
+
@executor.post { run }
|
36
|
+
Process.setproctitle("skiplock: #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2} [#{Rails.application.class.name.deconstantize.downcase}:#{Rails.env}]") if @config[:standalone]
|
51
37
|
end
|
52
38
|
|
39
|
+
private
|
40
|
+
|
53
41
|
def run
|
42
|
+
sleep 3
|
43
|
+
Skiplock.logger.info "[Skiplock] Starting in #{@config[:standalone] ? 'standalone' : 'async'} mode (PID: #{self.pid}) with #{@config[:max_threads]} max threads as #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2}..."
|
44
|
+
connection = nil
|
54
45
|
error = false
|
55
46
|
listen = false
|
56
47
|
next_schedule_at = Time.now.to_f
|
48
|
+
pg_exception_timestamp = nil
|
57
49
|
timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
58
50
|
while @running
|
59
51
|
Rails.application.reloader.wrap do
|
60
52
|
begin
|
61
53
|
unless listen
|
62
|
-
|
63
|
-
|
54
|
+
connection = self.class.connection
|
55
|
+
connection.exec_query('LISTEN "skiplock::jobs"')
|
56
|
+
if self.master
|
57
|
+
Job.flush
|
58
|
+
Cron.setup
|
59
|
+
end
|
64
60
|
listen = true
|
65
61
|
end
|
66
62
|
if error
|
67
|
-
unless
|
68
|
-
|
63
|
+
unless connection.active?
|
64
|
+
connection.reconnect!
|
69
65
|
sleep(0.5)
|
70
|
-
|
66
|
+
connection.exec_query('LISTEN "skiplock::jobs"')
|
67
|
+
Job.flush if self.master
|
68
|
+
pg_exception_timestamp = nil
|
71
69
|
next_schedule_at = Time.now.to_f
|
72
70
|
end
|
73
|
-
Job.flush if self.master
|
74
71
|
error = false
|
75
72
|
end
|
76
|
-
if Time.now.to_f >= next_schedule_at && @executor.remaining_capacity >
|
77
|
-
|
78
|
-
|
79
|
-
|
73
|
+
if Time.now.to_f >= next_schedule_at && @executor.remaining_capacity > 1 # reserves 1 slot in queue for Job.flush in case of pg_connection error
|
74
|
+
result = nil
|
75
|
+
connection.transaction do
|
76
|
+
result = connection.select_all("SELECT id, running, scheduled_at FROM skiplock.jobs WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL ORDER BY scheduled_at ASC NULLS FIRST,#{@queues_order_query ? ' CASE ' + @queues_order_query + ' ELSE NULL END ASC NULLS LAST,' : ''} priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").first
|
77
|
+
result = connection.select_all("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{result['id']}' RETURNING *").first if result && result['scheduled_at'].to_f <= Time.now.to_f
|
78
|
+
end
|
79
|
+
if result && result['running']
|
80
|
+
@executor.post do
|
81
|
+
Rails.application.executor.wrap { Job.instantiate(result).execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) }
|
82
|
+
end
|
80
83
|
else
|
81
|
-
next_schedule_at = (
|
84
|
+
next_schedule_at = (result ? result['scheduled_at'].to_f : Float::INFINITY)
|
82
85
|
end
|
83
86
|
end
|
84
87
|
job_notifications = []
|
85
|
-
|
88
|
+
connection.raw_connection.wait_for_notify(0.2) do |channel, pid, payload|
|
86
89
|
job_notifications << payload if payload
|
87
90
|
loop do
|
88
|
-
payload =
|
91
|
+
payload = connection.raw_connection.notifies
|
89
92
|
break unless @running && payload
|
90
93
|
job_notifications << payload[:extra]
|
91
94
|
end
|
@@ -96,22 +99,26 @@ module Skiplock
|
|
96
99
|
end
|
97
100
|
end
|
98
101
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
|
99
|
-
self.
|
102
|
+
connection.exec_query("UPDATE skiplock.workers SET updated_at = NOW() WHERE id = '#{self.id}'")
|
100
103
|
timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
101
104
|
end
|
102
105
|
rescue Exception => ex
|
103
|
-
# most likely error with database connection
|
104
106
|
Skiplock.logger.error(ex.to_s)
|
105
107
|
Skiplock.logger.error(ex.backtrace.join("\n"))
|
106
|
-
|
108
|
+
report_exception = true
|
109
|
+
# if error is with database connection then only report if it persists longer than 1 minute
|
110
|
+
if ex.is_a?(::PG::ConnectionBad) ||ex.is_a?(::PG::UnableToSend) || ex.message.include?('Bad file descriptor')
|
111
|
+
report_exception = false if pg_exception_timestamp.nil? || Process.clock_gettime(Process::CLOCK_MONOTONIC) - pg_exception_timestamp <= 60
|
112
|
+
pg_exception_timestamp ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
113
|
+
end
|
114
|
+
Skiplock.on_errors.each { |p| p.call(ex) } if report_exception
|
107
115
|
error = true
|
108
116
|
wait(5)
|
109
|
-
@last_exception = ex
|
110
117
|
end
|
111
118
|
sleep(0.3)
|
112
119
|
end
|
113
120
|
end
|
114
|
-
|
121
|
+
connection.exec_query('UNLISTEN *')
|
115
122
|
end
|
116
123
|
|
117
124
|
def wait(timeout)
|
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.22
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tin Vo
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-09-
|
11
|
+
date: 2021-09-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activejob
|
@@ -109,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
109
109
|
- !ruby/object:Gem::Version
|
110
110
|
version: '0'
|
111
111
|
requirements: []
|
112
|
-
rubygems_version: 3.
|
112
|
+
rubygems_version: 3.2.22
|
113
113
|
signing_key:
|
114
114
|
specification_version: 4
|
115
115
|
summary: ActiveJob Queue Adapter for PostgreSQL
|