skiplock 1.0.17 → 1.0.22
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 +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
|