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 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