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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9533e3df15a24e784000c160588b96207271986bf927bff095752e556c3376a
4
- data.tar.gz: dcc40ab796f55fc41363d671d5e4f73600c58c468d7c4543e496fad38c3e9a8a
3
+ metadata.gz: 3b99466d7f1848f9dcd750a4dceb6a39d7124707e941c74b6c75f0b3a57944ff
4
+ data.tar.gz: 3f3ed1c9327d41e675d5c170590b52fe32f1facc5b57667dba513e5324e6ee20
5
5
  SHA512:
6
- metadata.gz: 0a0c21835b52f749022d7f3a135f041d122425defffaf6aebb6d8510d4dd0836aed2fb9977daff9e7d3bf2547e139d2dd3ea49f5f55a869ce851c93aaeb56a12
7
- data.tar.gz: d94c6801c82e1bddb12cff0152b71e8554d03de4d28f51ec0d742807311adecca1b3b142aeadf3cd5e81336416b03206d81848f6da6abf8e0f8c726631a5d898
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 ran successfully
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', '{"arguments":[1,2,3],"options":{"purge":false,"max_retries":5}}');
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, previous|
175
- if ex.backtrace != previous.try(:backtrace)
176
- # sends text message using Amazon SNS on new exceptions only
177
- # the same repeated exceptions will only be sent once to avoid SPAM
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. 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']`
187
- - An example of remote execution if the extension is enabled globally (ie: configuration is set to `true`) and attacker can insert `skiplock.jobs`
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) VALUES ('Skiplock::Extension::ProxyJob', '{"arguments":["---\n- !ruby/module ''Kernel''\n- :system\n- - rm -rf /tmp/*\n"]}');
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 (record.running = TRUE) THEN
50
- IF (record.executions > 0) THEN
51
- INSERT INTO skiplock.counters (day,retries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET retries = skiplock.counters.retries + 1;
52
- ELSE
53
- INSERT INTO skiplock.counters (day,dispatches) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET dispatches = skiplock.counters.dispatches + 1;
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 = 't'"
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
@@ -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 = 'created_at'
4
- attr_accessor :activejob_retry
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].activejob_retry = true
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 retry any abandoned jobs
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 = Marshal.load(File.binread(f)) rescue nil
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 @max_retries
57
- dump = Marshal.dump(self)
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 @exception
63
- self.exception_executions["[#{@exception.class.name}]"] = self.exception_executions["[#{@exception.class.name}]"].to_i + 1 unless self.activejob_retry
64
- if (self.executions.to_i >= @max_retries + 1) || self.activejob_retry
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 @purge == true
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.binwrite("tmp/skiplock/#{self.id}", dump) rescue nil
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.activejob_retry = false
108
- @max_retries = (self.data['options'].key?('max_retries') ? self.data['options']['max_retries'].to_i : max_retries) rescue max_retries
109
- @max_retries = 20 if @max_retries < 0 || @max_retries > 20
110
- @purge = (self.data['options'].key?('purge') ? self.data['options']['purge'] : purge_completion) rescue purge_completion
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
- @exception = ex
119
- Skiplock.on_errors.each { |p| p.call(@exception) }
125
+ self.exception = ex
126
+ Skiplock.on_errors.each { |p| p.call(ex) }
120
127
  end
121
128
  if Skiplock.logger
122
- if @exception || self.activejob_retry
123
- Skiplock.logger.error("[Skiplock] Job #{self.job_class} (#{self.id}) was interrupted by an exception#{ ' (rescued and retried by ActiveJob)' if self.activejob_retry }")
124
- if @exception
125
- Skiplock.logger.error(@exception.to_s)
126
- Skiplock.logger.error(@exception.backtrace.join("\n"))
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.activejob_retry
147
+ self.finished_at ||= Time.now if self.data.key?('result') && !self.activejob_error
140
148
  self.dispose
141
149
  end
142
150
  end
@@ -1,20 +1,25 @@
1
1
  module Skiplock
2
2
  class Manager
3
- def initialize(**config)
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
- @config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
8
- @config.merge!(config)
9
- @hostname = Socket.gethostname
10
- configure
11
- setup_logger
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
- @logger.info "[Skiplock] Shutdown completed."
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, previous|
108
- Airbrake.notify_sync(ex) unless ex.backtrace == previous.try(:backtrace)
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, previous|
113
- Bugsnag.notify(ex) unless ex.backtrace == previous.try(:backtrace)
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, previous|
118
- ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace)
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
- Rails.application.eager_load! if Rails.env.development?
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.downcase)
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].downcase.to_sym
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")
@@ -1,7 +1,7 @@
1
1
  module Skiplock
2
2
  module Patch
3
3
  def enqueue(options = {})
4
- self.instance_variable_set('@skiplock_options', options)
4
+ @skiplock_options = options
5
5
  super
6
6
  end
7
7
  end
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.17'
2
+ VERSION = Version = '1.0.22'
3
3
  end
4
4
 
@@ -1,6 +1,6 @@
1
1
  module Skiplock
2
2
  class Worker < ActiveRecord::Base
3
- self.implicit_order_column = 'created_at'
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
- private
42
-
43
- def get_next_available_job
44
- @connection.transaction do
45
- job = Job.find_by_sql("SELECT id, scheduled_at FROM skiplock.jobs WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL ORDER BY scheduled_at ASC NULLS FIRST,#{@queues_order_query ? ' CASE ' + @queues_order_query + ' ELSE NULL END ASC NULLS LAST,' : ''} priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").first
46
- if job && job.scheduled_at.to_f <= Time.now.to_f
47
- job = Job.find_by_sql("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
48
- end
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
- @connection = self.class.connection
63
- @connection.exec_query('LISTEN "skiplock::jobs"')
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 @connection.active?
68
- @connection.reconnect!
63
+ unless connection.active?
64
+ connection.reconnect!
69
65
  sleep(0.5)
70
- @connection.exec_query('LISTEN "skiplock::jobs"')
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 > 0
77
- job = get_next_available_job
78
- if job.try(:running)
79
- @executor.post { Rails.application.reloader.wrap { job.execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) } }
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 = (job ? job.scheduled_at.to_f : Float::INFINITY)
84
+ next_schedule_at = (result ? result['scheduled_at'].to_f : Float::INFINITY)
82
85
  end
83
86
  end
84
87
  job_notifications = []
85
- @connection.raw_connection.wait_for_notify(0.4) do |channel, pid, payload|
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 = @connection.raw_connection.notifies
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.touch
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
- Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
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
- @connection.exec_query('UNLISTEN *')
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.17
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-12 00:00:00.000000000 Z
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.0.3
112
+ rubygems_version: 3.2.22
113
113
  signing_key:
114
114
  specification_version: 4
115
115
  summary: ActiveJob Queue Adapter for PostgreSQL