skiplock 1.0.14 → 1.0.18

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: 55c98833e2792a216b6389d399e992aae9660f23ea076fcf852fb164cbc32d55
4
- data.tar.gz: 43c8c2f90ee55ec423242042d1ab2986c0cb9f70fcc04db56aaf2c566bbb0382
3
+ metadata.gz: 8112589dcbdf58a743529ec9bf5ddd8371791e83e79c0d455ed42cbca05ee5eb
4
+ data.tar.gz: 149f826e93ecd8ac6165a8db46a8e7e234636a4481e68bf09d208b04a1dea1ce
5
5
  SHA512:
6
- metadata.gz: 873492b815e6b5fc653630fed3bc64c5a363339551bbad304ecbad9453ea3738c24f3f0b874f4d6fe47cd4cc0ea5d46fb61b4c0dc322814d08dbc11002c24443
7
- data.tar.gz: e84f7081e07ba0aff19834251de09e08df573d45ff159299fe08baf621b76845cd2fdf0461229da77b07cf707f151e7d8fc00d97b19a9bf30f1129376c2dd053
6
+ metadata.gz: 437f622f2398a0a6c965ea1720f77f11b9241373f277e249e4f3d39afbd3bccc7d8ad2b919abd141ebc5bc6a2543408a7940e53f2968c3cda9fc0519ac387910
7
+ data.tar.gz: '0473939dc2497842ff66fb02e2f74371f7281d58cc5ac82f1999b669f5b45e526d5d75054a3cc95594e26fc5f4e4aafc42e15d2f7336b88223256d6f7ac46d4e'
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  It only uses the `LISTEN/NOTIFY/SKIP LOCKED` features provided natively on PostgreSQL 9.5+ to efficiently and reliably dispatch jobs to worker processes and threads ensuring that each job can be completed successfully **only once**. No other polling or timer is needed.
6
6
 
7
- The library is quite small compared to other PostgreSQL job queues (eg. *delay_job*, *queue_classic*, *que*, *good_job*) with less than 400 lines of codes; and it still provides similar set of features and more...
7
+ The library is quite small compared to other PostgreSQL job queues (eg. *delay_job*, *queue_classic*, *que*, *good_job*) with less than 500 lines of codes; and it still provides similar set of features and more...
8
8
 
9
9
  #### Compatibility:
10
10
 
@@ -51,9 +51,9 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
51
51
  # config/skiplock.yml (default settings)
52
52
  ---
53
53
  min_threads: 1
54
- max_threads: 5
54
+ max_threads: 10
55
55
  max_retries: 20
56
- logfile: log/skiplock.log
56
+ logfile: skiplock.log
57
57
  loglevel: info
58
58
  notification: custom
59
59
  extensions: false
@@ -67,11 +67,11 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
67
67
  - **min_threads** (*integer*): sets minimum number of threads staying idle
68
68
  - **max_threads** (*integer*): sets the maximum number of threads allowed to run jobs
69
69
  - **max_retries** (*integer*): sets the maximum attempt a job will be retrying before it is marked expired. See `Retry system` for more details
70
- - **logfile** (*string*): path filename for skiplock logs; empty logfile will disable logging
70
+ - **logfile** (*string*): filename for skiplock logs; empty logfile will disable logging
71
71
  - **loglevel** (*string*): sets logging level (`debug, info, warn, error, fatal, unknown`)
72
72
  - **notification** (*string*): sets the library to be used for notifying errors and exceptions (`auto, airbrake, bugsnag, exception_notification, custom`); using `auto` will detect library if available. See `Notification system` for more details
73
- - **extensions** (*boolean*): enable or disable the class method extension. See `ClassMethod extension` for more details
74
- - **purge_completion** (*boolean*): when set to **true** will delete jobs after they were completed successfully; if set to **false** then the completed jobs should be purged periodically to maximize performance (eg. clean up old jobs after 3 months)
73
+ - **extensions** (*multi*): enable or disable the class method extension. See `ClassMethod extension` for more details
74
+ - **purge_completion** (*boolean*): when set to **true** will delete jobs after they were completed successfully; if set to **false** then the completed jobs should be purged periodically to maximize performance (eg. clean up old jobs after 3 months); queued jobs can manually override using `purge` option
75
75
  - **queues** (*hash*): defines the set of queues with priorities; lower priority takes precedence
76
76
  - **workers** (*integer*) sets the maximum number of processes when running in standalone mode using the `skiplock` executable; setting this to **0** will enable **async mode**
77
77
 
@@ -84,11 +84,12 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
84
84
  $ bundle exec skiplock -h
85
85
  Usage: skiplock [options]
86
86
  -e, --environment STRING Rails environment
87
- -l, --logfile STRING Full path to logfile
87
+ -l, --logfile STRING Log filename
88
88
  -s, --graceful-shutdown NUM Number of seconds to wait for graceful shutdown
89
89
  -r, --max-retries NUM Number of maxixum retries
90
90
  -t, --max-threads NUM Number of maximum threads
91
91
  -T, --min-threads NUM Number of minimum threads
92
+ -v, --version Show version information
92
93
  -w, --workers NUM Number of workers
93
94
  -h, --help Show this message
94
95
  ```
@@ -99,19 +100,27 @@ Inside the Rails application:
99
100
  ```ruby
100
101
  MyJob.perform_later
101
102
  ```
102
- - Skiplock supports all ActiveJob features
103
+ - Skiplock supports all ActiveJob features and options
103
104
  ```ruby
104
105
  MyJob.set(queue: 'my_queue', wait: 5.minutes, priority: 10).perform_later(1,2,3)
106
+ MyJob.set(wait_until: Day.tomorrow.noon).perform_later(1,2,3)
107
+ ```
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 completed successfully
110
+ - **max_retries** (*integer*): set maximum retry attempt for this job
111
+ ```ruby
112
+ MyJob.set(purge: false, max_retries: 5).perform_later(1,2,3)
105
113
  ```
106
114
  Outside the Rails application:
107
115
  - queue the jobs by inserting the job records directly to the database table
108
116
  ```sql
109
117
  INSERT INTO skiplock.jobs(job_class) VALUES ('MyJob');
110
118
  ```
111
- - with scheduling, priority, queue and arguments
119
+ - with scheduling, priority, queue, arguments and custom options
112
120
  ```sql
113
121
  INSERT INTO skiplock.jobs(job_class, queue_name, priority, scheduled_at, data)
114
- VALUES ('MyJob', 'my_queue', 10, NOW() + INTERVAL '5 min', '{"arguments":[1,2,3]}');
122
+ VALUES ('MyJob', 'my_queue', 10, NOW() + INTERVAL '5 min',
123
+ '{"arguments":[1,2,3],"options":{"purge":false,"max_retries":5}}');
115
124
  ```
116
125
  ## Queue priority vs Job priority
117
126
  *Why do queues use priorities when jobs already have priorities?*
@@ -155,9 +164,9 @@ Outside the Rails application:
155
164
  # ...
156
165
  end
157
166
  ```
158
- If the retry attempt limit configured in ActiveJob has been reached, then the control will be passed back to `skiplock` to be marked as an expired job.
167
+ If the retry attempt limit configured in ActiveJob has been reached, then the control will be passed back to `Skiplock` to be marked as an expired job.
159
168
 
160
- If the `retry_on` block is not defined, then the built-in retry system of `skiplock` will kick in automatically. The retrying schedule is using an exponential formula (5 + 2**attempt). The `skiplock` configuration `max_retries` determines the the limit of attempts before the failing job is marked as expired. The maximum retry limit can be set as high as 20; this allows up to 12 days of retrying before the job is marked as expired.
169
+ If the `retry_on` block is not defined, then the built-in retry system of `Skiplock` will kick in automatically. The retrying schedule is using an exponential formula (5 + 2**attempt). The `Skiplock` configuration `max_retries` determines the the limit of attempts before the failing job is marked as expired. The maximum retry limit can be set as high as 20; this allows up to 12 days of retrying before the job is marked as expired.
161
170
 
162
171
  ## Notification system
163
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:
@@ -165,15 +174,25 @@ If the `retry_on` block is not defined, then the built-in retry system of `skipl
165
174
  # config/initializers/skiplock.rb
166
175
  Skiplock.on_error do |ex, previous|
167
176
  if ex.backtrace != previous.try(:backtrace)
168
- # sends custom email on new exceptions only
177
+ # sends text message using Amazon SNS on new exceptions only
169
178
  # the same repeated exceptions will only be sent once to avoid SPAM
170
179
  # NOTE: exceptions generated from Job executions will not provide 'previous' exceptions
180
+ 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])
181
+ sms.publish({ phone_number: '+122233334444', message: "Exception: #{ex.message}"[0..130] })
171
182
  end
172
183
  end
173
184
  # supports multiple 'on_error' event callbacks
174
185
  ```
175
186
  ## ClassMethod extension
176
- `Skiplock` can add extension to allow all class methods to be performed as a background job; it is disabled in the default configuration. To enable, edit the `config/skiplock.yml` configuration file and change `extensions` to `true`.
187
+ `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.
188
+
189
+ 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']`
190
+ - An example of remote code execution if the extension is enabled globally (ie: configuration is set to `true`) and attacker can insert `skiplock.jobs`
191
+ ```sql
192
+ INSERT INTO skiplock.jobs(job_class, data)
193
+ VALUES ('Skiplock::Extension::ProxyJob',
194
+ '{"arguments":["---\n- !ruby/module ''Kernel''\n- :system\n- - rm -rf /tmp/*\n"]}');
195
+ ```
177
196
  - Queue class method `generate_thumbnails` of class `Image` as background job to run as soon as possible
178
197
  ```ruby
179
198
  Image.skiplock.generate_thumbnails(height: 100, ratio: true)
@@ -187,6 +206,42 @@ If the `retry_on` block is not defined, then the built-in retry system of `skipl
187
206
  Subscription.skiplock(wait_until: Date.tomorrow.noon).charge(amount: 100)
188
207
  ```
189
208
 
209
+ ## Fault tolerant
210
+ `Skiplock` ensures that jobs will be executed sucessfully only once even if database connection is lost during or after the job was dispatched. Successful jobs are marked as completed or removed (with `purge_completion` global configuration or `purge` job option); failed or interrupted jobs are marked for retry.
211
+
212
+ However, when the database connection is dropped for any reasons and the commit is lost, `Skiplock` will then save the commit data to local disk (as `tmp/skiplock/<job_id>`) and synchronize with the database when the connection resumes.
213
+
214
+ This also protects long running in-progress jobs that are terminated abruptly during a graceful shutdown with timeout; these will be queued for retry.
215
+
216
+ ## Scalability
217
+ `Skiplock` can scale both vertically and horizontally. To scale vertically, simply increase the number of `Skiplock` workers per host. To scale horizontally, simply deploy `Skiplock` to multiple hosts sharing the same PostgreSQL database.
218
+
219
+ ## Statistics, analytics and counters
220
+ The `skiplock.workers` database table contains all the `Skiplock` workers running on all the hosts. Active worker will update its timestamp column (`updated_at`) every minute; and dispatched jobs would be associated with the running workers. At any given time, a list of active workers running a list of jobs can be determined using the database table.
221
+
222
+ The `skiplock.jobs` database table contains all the `Skiplob` jobs. Each job's successful execution stores the result to its `data['result']` field column. If job completions are not purged then their execution results can be used for analytic purposes.
223
+
224
+ The `skiplock.counters` database table contains all the counters for job dispatches, completions, expiries, failures and retries. The counters are recorded by dates; so it's possible to get statistical data for any given day or range of dates.
225
+ - **completions**: numbers of jobs completed successfully
226
+ - **dispatches**: number of jobs dispatched for the first time (**retries** are not counted here)
227
+ - **expiries**: number of jobs exceeded `max_retries` and still failed to complete
228
+ - **failures**: number of jobs interrupted by graceful shutdown or unable to complete due to errors (exceptions)
229
+ - **retries**: number of jobs dispatched for retrying
230
+
231
+ Code examples of gathering counters information:
232
+ - get counter information for today
233
+ ```ruby
234
+ Skiplock::Counter.where(day: Date.today).first
235
+ ```
236
+ - get total number of successfully completed jobs within the past 30 days
237
+ ```ruby
238
+ Skiplock::Counter.where("day >= ?", 30.days.ago).sum(:completions)
239
+ ```
240
+ - get total number of expired jobs
241
+ ```ruby
242
+ Skiplock::Counter.sum(:expiries)
243
+ ```
244
+
190
245
  ## Contributing
191
246
 
192
247
  Bug reports and pull requests are welcome on GitHub at https://github.com/vtt/skiplock.
data/bin/skiplock CHANGED
@@ -1,15 +1,20 @@
1
1
  #!/usr/bin/env ruby
2
2
  require 'optparse'
3
+ require_relative '../lib/skiplock/version'
3
4
  options = {}
4
5
  begin
5
6
  op = OptionParser.new do |opts|
6
7
  opts.banner = "Usage: #{File.basename($0)} [options]"
7
8
  opts.on('-e', '--environment STRING', String, 'Rails environment')
8
- opts.on('-l', '--logfile STRING', String, 'Full path to logfile')
9
+ opts.on('-l', '--logfile STRING', String, 'Log filename')
9
10
  opts.on('-s', '--graceful-shutdown NUM', Integer, 'Number of seconds to wait for graceful shutdown')
10
11
  opts.on('-r', '--max-retries NUM', Integer, 'Number of maxixum retries')
11
12
  opts.on('-t', '--max-threads NUM', Integer, 'Number of maximum threads')
12
13
  opts.on('-T', '--min-threads NUM', Integer, 'Number of minimum threads')
14
+ opts.on('-v', '--version', 'Show version information') do
15
+ puts "Skiplock Version #{Skiplock::VERSION}"
16
+ exit
17
+ end
13
18
  opts.on('-w', '--workers NUM', Integer, 'Number of workers')
14
19
  opts.on_tail('-h', '--help', 'Show this message') do
15
20
  exit
@@ -25,4 +30,4 @@ options.transform_keys! { |k| k.to_s.gsub('-', '_').to_sym }
25
30
  env = options.delete(:environment)
26
31
  ENV['RAILS_ENV'] = env if env
27
32
  require File.expand_path("config/environment.rb")
28
- Rails.application.config.skiplock.standalone(**options.merge(standalone: true))
33
+ Rails.application.config.skiplock.standalone(**options)
@@ -43,21 +43,20 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
43
43
  record = NEW;
44
44
  IF (TG_OP = 'DELETE') THEN
45
45
  record = OLD;
46
- IF (record.finished_at IS NOT NULL OR record.expired_at IS NOT NULL) THEN
47
- RETURN NULL;
46
+ IF (record.running = TRUE) THEN
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
- INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
50
49
  ELSIF (record.running = TRUE) THEN
51
- IF (record.executions IS NULL) THEN
52
- INSERT INTO skiplock.counters (day,dispatches) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET dispatches = skiplock.counters.dispatches + 1;
53
- ELSE
50
+ IF (record.executions > 0) THEN
54
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;
55
54
  END IF;
56
55
  ELSIF (record.finished_at IS NOT NULL) THEN
57
56
  INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
58
57
  ELSIF (record.expired_at IS NOT NULL) THEN
59
58
  INSERT INTO skiplock.counters (day,expiries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET expiries = skiplock.counters.expiries + 1;
60
- ELSIF (record.executions IS NOT NULL AND record.scheduled_at IS NOT NULL) THEN
59
+ ELSIF (TG_OP = 'UPDATE' AND OLD.running = TRUE AND NEW.running = FALSE) THEN
61
60
  INSERT INTO skiplock.counters (day,failures) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET failures = skiplock.counters.failures + 1;
62
61
  END IF;
63
62
  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));
@@ -1,4 +1,5 @@
1
1
  module Skiplock
2
2
  class Counter < ActiveRecord::Base
3
+ self.implicit_order_column = 'day'
3
4
  end
4
5
  end
data/lib/skiplock/cron.rb CHANGED
@@ -2,17 +2,16 @@ require 'cron_parser'
2
2
  module Skiplock
3
3
  class Cron
4
4
  def self.setup
5
- Rails.application.eager_load! if Rails.env.development?
6
5
  cronjobs = []
7
6
  ActiveJob::Base.descendants.each do |j|
8
7
  next unless j.const_defined?('CRON')
9
8
  cron = j.const_get('CRON')
10
- job = Job.find_by('job_class = ? AND cron IS NOT NULL', j.name) || Job.new(job_class: j.name, cron: cron, locale: I18n.locale, timezone: Time.zone.name)
9
+ job = Job.find_by('job_class = ? AND cron IS NOT NULL', j.name) || Job.new(job_class: j.name, queue_name: j.queue_as, cron: cron, locale: I18n.locale, timezone: Time.zone.name)
11
10
  time = self.next_schedule_at(cron)
12
11
  if time
13
12
  job.cron = cron
14
13
  job.running = false
15
- 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
16
15
  job.save
17
16
  cronjobs << j.name
18
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,15 +1,17 @@
1
1
  module Skiplock
2
2
  class Job < ActiveRecord::Base
3
- self.implicit_order_column = 'created_at'
3
+ self.implicit_order_column = 'updated_at'
4
+ attr_accessor :activejob_retry
5
+ belongs_to :worker, inverse_of: :jobs, required: false
4
6
 
5
- def self.dispatch(worker_id: nil, purge_completion: true, max_retries: 20)
7
+ def self.dispatch(purge_completion: true, max_retries: 20)
6
8
  job = nil
7
9
  self.connection.transaction do
8
10
  job = self.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, priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").first
9
11
  return if job.nil? || job.scheduled_at.to_f > Time.now.to_f
10
- job = self.find_by_sql("UPDATE skiplock.jobs SET running = TRUE, worker_id = #{self.connection.quote(worker_id)}, updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
12
+ job = self.find_by_sql("UPDATE skiplock.jobs SET running = TRUE, updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
11
13
  end
12
- self.dispatch(worker_id: worker_id, purge_completion: purge_completion, max_retries: max_retries) if job.execute(purge_completion: purge_completion, max_retries: max_retries)
14
+ self.dispatch(purge_completion: purge_completion, max_retries: max_retries) if job.execute(purge_completion: purge_completion, max_retries: max_retries)
13
15
  end
14
16
 
15
17
  def self.enqueue(activejob)
@@ -18,87 +20,113 @@ module Skiplock
18
20
 
19
21
  def self.enqueue_at(activejob, timestamp)
20
22
  timestamp = Time.at(timestamp) if timestamp
21
- if Thread.current[:skiplock_dispatch_job].try(:id) == activejob.job_id
22
- Thread.current[:skiplock_dispatch_job].exception_executions = activejob.exception_executions.merge('activejob_retry' => true)
23
- Thread.current[:skiplock_dispatch_job].executions = activejob.executions
24
- Thread.current[:skiplock_dispatch_job].scheduled_at = timestamp
25
- Thread.current[:skiplock_dispatch_job]
23
+ if Thread.current[:skiplock_job].try(:id) == activejob.job_id
24
+ Thread.current[:skiplock_job].activejob_retry = true
25
+ Thread.current[:skiplock_job].data['activejob_retry'] = true
26
+ Thread.current[:skiplock_job].executions = activejob.executions
27
+ Thread.current[:skiplock_job].exception_executions = activejob.exception_executions
28
+ Thread.current[:skiplock_job].scheduled_at = timestamp
29
+ Thread.current[:skiplock_job]
26
30
  else
31
+ options = activejob.instance_variable_get('@skiplock_options') || {}
27
32
  serialize = activejob.serialize
28
- self.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'] }, 'scheduled_at' => timestamp))
33
+ self.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'], 'options' => options }, 'scheduled_at' => timestamp))
29
34
  end
30
35
  end
31
36
 
37
+ # resynchronize jobs that could not commit to database and reset any abandoned jobs for retry
38
+ def self.flush
39
+ Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
40
+ Dir.glob('tmp/skiplock/*').each do |f|
41
+ disposed = true
42
+ if self.exists?(id: File.basename(f), running: true)
43
+ job = Marshal.load(File.binread(f)) rescue nil
44
+ disposed = job.dispose if job.is_a?(Skiplock::Job)
45
+ end
46
+ (File.delete(f) rescue nil) if disposed
47
+ end
48
+ self.where(running: true).where.not(worker_id: Worker.ids).update_all(running: false, worker_id: nil)
49
+ true
50
+ end
51
+
32
52
  def self.reset_retry_schedules
33
- self.where('scheduled_at > NOW() AND executions IS NOT NULL AND expired_at IS NULL AND finished_at IS NULL').update_all(scheduled_at: nil, updated_at: Time.now)
53
+ self.where('scheduled_at > NOW() AND executions > 0 AND expired_at IS NULL AND finished_at IS NULL').update_all(scheduled_at: nil, updated_at: Time.now)
34
54
  end
35
55
 
36
- def dispose(ex, purge_completion: true, max_retries: 20)
37
- yaml = [self, ex].to_yaml
56
+ def dispose
57
+ return unless @max_retries
58
+ dump = Marshal.dump(self)
59
+ purging = false
38
60
  self.running = false
39
61
  self.worker_id = nil
40
- self.updated_at = (Time.now > self.updated_at ? Time.now : self.updated_at + 1)
41
- if ex
42
- self.exception_executions["[#{ex.class.name}]"] = (self.exception_executions["[#{ex.class.name}]"] || 0) + 1 unless self.exception_executions.key?('activejob_retry')
43
- if self.executions >= max_retries || self.exception_executions.key?('activejob_retry')
62
+ self.updated_at = Time.now > self.updated_at ? Time.now : self.updated_at + 1 # in case of clock drifting
63
+ if @exception
64
+ self.exception_executions["[#{@exception.class.name}]"] = self.exception_executions["[#{@exception.class.name}]"].to_i + 1 unless self.activejob_retry
65
+ if (self.executions.to_i >= @max_retries + 1) || self.data.key?('activejob_retry') || @exception.is_a?(Skiplock::Extension::ProxyError)
44
66
  self.expired_at = Time.now
45
67
  else
46
- self.scheduled_at = Time.now + (5 * 2**self.executions)
68
+ self.scheduled_at = Time.now + (5 * 2**self.executions.to_i)
47
69
  end
48
- self.save!
49
- Skiplock.on_errors.each { |p| p.call(ex) }
50
- elsif self.exception_executions.try(:key?, 'activejob_retry')
51
- self.save!
52
- elsif self.cron
53
- self.data ||= {}
54
- self.data['crons'] = (self.data['crons'] || 0) + 1
55
- self.data['last_cron_at'] = Time.now.utc.to_s
56
- next_cron_at = Cron.next_schedule_at(self.cron)
57
- if next_cron_at
58
- self.executions = nil
59
- self.exception_executions = nil
60
- self.scheduled_at = Time.at(next_cron_at)
61
- self.save!
62
- else
63
- Skiplock.logger.error("[Skiplock] ERROR: Invalid CRON '#{self.cron}' for Job #{self.job_class}") if Skiplock.logger
64
- self.delete
70
+ elsif self.finished_at
71
+ if self.cron
72
+ self.data['cron'] ||= {}
73
+ self.data['cron']['executions'] = self.data['cron']['executions'].to_i + 1
74
+ self.data['cron']['last_finished_at'] = self.finished_at.utc.to_s
75
+ self.data['cron']['last_result'] = self.data['result']
76
+ next_cron_at = Cron.next_schedule_at(self.cron)
77
+ if next_cron_at
78
+ # update job to record completions counter before resetting finished_at to nil
79
+ self.update_columns(self.attributes.slice(*self.changes.keys))
80
+ self.finished_at = nil
81
+ self.executions = nil
82
+ self.exception_executions = nil
83
+ self.data.delete('result')
84
+ self.scheduled_at = Time.at(next_cron_at)
85
+ else
86
+ Skiplock.logger.error("[Skiplock] ERROR: Invalid CRON '#{self.cron}' for Job #{self.job_class}") if Skiplock.logger
87
+ purging = true
88
+ end
89
+ elsif @purge == true
90
+ purging = true
65
91
  end
66
- elsif purge_completion
67
- self.delete
68
- else
69
- self.finished_at = Time.now
70
- self.exception_executions = nil
71
- self.save!
72
92
  end
73
- self
93
+ purging ? self.delete : self.update_columns(self.attributes.slice(*self.changes.keys))
74
94
  rescue Exception => e
95
+ File.binwrite("tmp/skiplock/#{self.id}", dump) rescue nil
75
96
  if Skiplock.logger
76
97
  Skiplock.logger.error(e.to_s)
77
98
  Skiplock.logger.error(e.backtrace.join("\n"))
78
- File.write("tmp/skiplock/#{self.id}", yaml)
79
99
  end
100
+ Skiplock.on_errors.each { |p| p.call(e) }
80
101
  nil
81
102
  end
82
103
 
83
104
  def execute(purge_completion: true, max_retries: 20)
105
+ raise 'Job has already been completed' if self.finished_at
84
106
  Skiplock.logger.info("[Skiplock] Performing #{self.job_class} (#{self.id}) from queue '#{self.queue_name || 'default'}'...") if Skiplock.logger
85
107
  self.data ||= {}
108
+ self.data.delete('result')
86
109
  self.exception_executions ||= {}
110
+ self.activejob_retry = false
111
+ @max_retries = (self.data['options'].key?('max_retries') ? self.data['options']['max_retries'].to_i : max_retries) rescue max_retries
112
+ @max_retries = 20 if @max_retries < 0 || @max_retries > 20
113
+ @purge = (self.data['options'].key?('purge') ? self.data['options']['purge'] : purge_completion) rescue purge_completion
87
114
  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'] || []))
88
- self.executions = (self.executions || 0) + 1
89
- Thread.current[:skiplock_dispatch_job] = self
90
- activejob = ActiveJob::Base.deserialize(job_data)
115
+ self.executions = self.executions.to_i + 1
116
+ Thread.current[:skiplock_job] = self
91
117
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
92
118
  begin
93
- activejob.perform_now
119
+ self.data['result'] = ActiveJob::Base.execute(job_data)
94
120
  rescue Exception => ex
121
+ @exception = ex
122
+ Skiplock.on_errors.each { |p| p.call(@exception) }
95
123
  end
96
124
  if Skiplock.logger
97
- if ex || self.exception_executions.key?('activejob_retry')
98
- Skiplock.logger.error("[Skiplock] Job #{self.job_class} (#{self.id}) was interrupted by an exception#{ ' (rescued and retried by ActiveJob)' if self.exception_executions.key?('activejob_retry') }")
99
- if ex
100
- Skiplock.logger.error(ex.to_s)
101
- Skiplock.logger.error(ex.backtrace.join("\n"))
125
+ if @exception || self.activejob_retry
126
+ Skiplock.logger.error("[Skiplock] Job #{self.job_class} (#{self.id}) was interrupted by an exception#{ ' (rescued and retried by ActiveJob)' if self.activejob_retry }")
127
+ if @exception
128
+ Skiplock.logger.error(@exception.to_s)
129
+ Skiplock.logger.error(@exception.backtrace.join("\n"))
102
130
  end
103
131
  else
104
132
  end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -110,7 +138,9 @@ module Skiplock
110
138
  Skiplock.logger.info "[Skiplock] Performed #{job_name} (#{self.id}) from queue '#{self.queue_name || 'default'}' in #{end_time - start_time} seconds"
111
139
  end
112
140
  end
113
- self.dispose(ex, purge_completion: purge_completion, max_retries: max_retries)
141
+ ensure
142
+ self.finished_at ||= Time.now if self.data.key?('result') && !self.activejob_retry
143
+ self.dispose
114
144
  end
115
145
  end
116
146
  end
@@ -1,17 +1,16 @@
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
7
  @config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
8
- @config.merge!(config)
9
- @config[:hostname] = `hostname -f`.strip
8
+ @hostname = Socket.gethostname
10
9
  configure
11
- Module.__send__(:include, Skiplock::Extension) if @config[:extensions] == true
10
+ setup_logger
12
11
  if (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0)
13
- cleanup_workers
14
- @worker = create_worker
12
+ Worker.cleanup(@hostname)
13
+ @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
15
14
  @worker.start(**@config)
16
15
  at_exit { @worker.shutdown }
17
16
  end
@@ -22,20 +21,22 @@ module Skiplock
22
21
 
23
22
  def standalone(**options)
24
23
  @config.merge!(options)
25
- Rails.logger.reopen('/dev/null')
24
+ Rails.logger.reopen('/dev/null') rescue Rails.logger.reopen('NUL') # supports Windows NUL device
26
25
  Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
27
26
  @config[:workers] = 1 if @config[:workers] <= 0
27
+ @config[:standalone] = true
28
28
  banner
29
- cleanup_workers
30
- @worker = create_worker
29
+ Worker.cleanup(@hostname)
30
+ @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
31
31
  @parent_id = Process.pid
32
32
  @shutdown = false
33
- Signal.trap("INT") { @shutdown = true }
34
- Signal.trap("TERM") { @shutdown = true }
33
+ Signal.trap('INT') { @shutdown = true }
34
+ Signal.trap('TERM') { @shutdown = true }
35
+ Signal.trap('HUP') { setup_logger }
35
36
  (@config[:workers] - 1).times do |n|
36
37
  fork do
37
38
  sleep 1
38
- worker = create_worker(master: false)
39
+ worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname, master: false)
39
40
  worker.start(worker_num: n + 1, **@config)
40
41
  loop do
41
42
  sleep 0.5
@@ -78,24 +79,7 @@ module Skiplock
78
79
  @logger.warn "[Skiplock] Custom notification has no registered 'on_error' callback" if Skiplock.on_errors.count == 0
79
80
  end
80
81
 
81
- def cleanup_workers
82
- delete_ids = []
83
- Worker.where(hostname: @config[:hostname]).each do |worker|
84
- sid = Process.getsid(worker.pid) rescue nil
85
- delete_ids << worker.id if worker.sid != sid || worker.updated_at < 30.minutes.ago
86
- end
87
- Worker.where(id: delete_ids).delete_all if delete_ids.count > 0
88
- Job.where(running: true).where.not(worker_id: Worker.ids).update_all(running: false, worker_id: nil)
89
- end
90
-
91
- def create_worker(master: true)
92
- Worker.create!(pid: Process.pid, sid: Process.getsid(), master: master, hostname: @config[:hostname], capacity: @config[:max_threads])
93
- rescue
94
- Worker.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: @config[:hostname], capacity: @config[:max_threads])
95
- end
96
-
97
82
  def configure
98
- @config[:loglevel] = 'info' unless ['debug','info','warn','error','fatal','unknown'].include?(@config[:loglevel].to_s)
99
83
  @config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
100
84
  @config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
101
85
  @config[:max_retries] = 20 if @config[:max_retries] > 20
@@ -104,15 +88,6 @@ module Skiplock
104
88
  @config[:max_threads] = 20 if @config[:max_threads] > 20
105
89
  @config[:min_threads] = 0 if @config[:min_threads] < 0
106
90
  @config[:workers] = 0 if @config[:workers] < 0
107
- @logger = ActiveSupport::Logger.new(STDOUT)
108
- @logger.level = @config[:loglevel].to_sym
109
- Skiplock.logger = @logger
110
- raise "Cannot create logfile '#{@config[:logfile]}'" if @config[:logfile] && !File.writable?(File.dirname(@config[:logfile]))
111
- @config[:logfile] = nil if @config[:logfile].to_s.length == 0
112
- if @config[:logfile]
113
- @logger.extend(ActiveSupport::Logger.broadcast(::Logger.new(@config[:logfile])))
114
- ActiveJob::Base.logger = nil
115
- end
116
91
  @config[:queues].values.each { |v| raise 'Queue value must be an integer' unless v.is_a?(Integer) } if @config[:queues].is_a?(Hash)
117
92
  if @config[:notification] == 'auto'
118
93
  if defined?(Airbrake)
@@ -144,7 +119,28 @@ module Skiplock
144
119
  else
145
120
  @config[:notification] = 'custom'
146
121
  end
122
+ Rails.application.eager_load! if Rails.env.development?
123
+ if @config[:extensions] == true
124
+ Module.__send__(:include, Skiplock::Extension)
125
+ elsif @config[:extensions].is_a?(Array)
126
+ @config[:extensions].each { |n| n.constantize.__send__(:extend, Skiplock::Extension) if n.safe_constantize }
127
+ end
147
128
  Skiplock.on_errors.freeze unless Skiplock.on_errors.frozen?
148
129
  end
130
+
131
+ def setup_logger
132
+ @config[:loglevel] = 'info' unless ['debug','info','warn','error','fatal','unknown'].include?(@config[:loglevel].to_s.downcase)
133
+ @logger = ActiveSupport::Logger.new(STDOUT)
134
+ @logger.level = @config[:loglevel].downcase.to_sym
135
+ Skiplock.logger = @logger
136
+ if @config[:logfile].to_s.length > 0
137
+ @logger.extend(ActiveSupport::Logger.broadcast(::Logger.new(File.join(Rails.root, 'log', @config[:logfile].to_s), 'daily')))
138
+ ActiveJob::Base.logger = nil
139
+ end
140
+ rescue Exception => ex
141
+ @logger.error "Exception with logger: #{ex.to_s}"
142
+ @logger.error ex.backtrace.join("\n")
143
+ Skiplock.on_errors.each { |p| p.call(ex) }
144
+ end
149
145
  end
150
146
  end
@@ -0,0 +1,8 @@
1
+ module Skiplock
2
+ module Patch
3
+ def enqueue(options = {})
4
+ self.instance_variable_set('@skiplock_options', options)
5
+ super
6
+ end
7
+ end
8
+ end
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.14'
2
+ VERSION = Version = '1.0.18'
3
3
  end
4
4
 
@@ -1,17 +1,34 @@
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
+ has_many :jobs, inverse_of: :worker
5
+
6
+ def self.cleanup(hostname = nil)
7
+ delete_ids = []
8
+ self.where(hostname: hostname || Socket.gethostname).each do |worker|
9
+ sid = Process.getsid(worker.pid) rescue nil
10
+ delete_ids << worker.id if worker.sid != sid || worker.updated_at < 10.minutes.ago
11
+ end
12
+ self.where(id: delete_ids).delete_all if delete_ids.count > 0
13
+ end
14
+
15
+ def self.generate(capacity:, hostname:, master: true)
16
+ self.create!(pid: Process.pid, sid: Process.getsid(), master: master, hostname: hostname, capacity: capacity)
17
+ rescue
18
+ self.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: hostname, capacity: capacity)
19
+ end
4
20
 
5
21
  def start(worker_num: 0, **config)
22
+ if self.master
23
+ Job.flush
24
+ Cron.setup
25
+ end
6
26
  @config = config
7
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
8
- @next_schedule_at = Time.now.to_f
9
- @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)
10
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 }
11
31
  Process.setproctitle("skiplock-#{self.master ? 'master[0]' : 'worker[' + worker_num.to_s + ']'}") if @config[:standalone]
12
- @executor.post do
13
- Rails.application.reloader.wrap { run }
14
- end
15
32
  end
16
33
 
17
34
  def shutdown
@@ -23,22 +40,9 @@ module Skiplock
23
40
 
24
41
  private
25
42
 
26
- def check_sync_errors
27
- # get executed jobs that could not sync with database
28
- Dir.glob('tmp/skiplock/*').each do |f|
29
- job_from_db = Job.find_by(id: File.basename(f), running: true)
30
- disposed = true
31
- if job_from_db
32
- job, ex = YAML.load_file(f) rescue nil
33
- disposed = job.dispose(ex, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) if job
34
- end
35
- File.delete(f) if disposed
36
- end
37
- end
38
-
39
43
  def get_next_available_job
40
44
  @connection.transaction do
41
- 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
45
+ job = Job.find_by_sql("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
42
46
  if job && job.scheduled_at.to_f <= Time.now.to_f
43
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
44
48
  end
@@ -47,38 +51,34 @@ module Skiplock
47
51
  end
48
52
 
49
53
  def run
50
- @connection = self.class.connection
51
- @connection.exec_query('LISTEN "skiplock::jobs"')
52
- if self.master
53
- Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
54
- check_sync_errors
55
- Cron.setup
56
- end
57
54
  error = false
55
+ listen = false
56
+ next_schedule_at = Time.now.to_f
58
57
  timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
58
  while @running
60
59
  Rails.application.reloader.wrap do
61
60
  begin
61
+ unless listen
62
+ @connection = self.class.connection
63
+ @connection.exec_query('LISTEN "skiplock::jobs"')
64
+ listen = true
65
+ end
62
66
  if error
63
67
  unless @connection.active?
64
68
  @connection.reconnect!
65
69
  sleep(0.5)
66
70
  @connection.exec_query('LISTEN "skiplock::jobs"')
67
- @next_schedule_at = Time.now.to_f
71
+ next_schedule_at = Time.now.to_f
68
72
  end
69
- check_sync_errors if self.master
73
+ Job.flush if self.master
70
74
  error = false
71
75
  end
72
- if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
76
+ if Time.now.to_f >= next_schedule_at && @executor.remaining_capacity > 0
73
77
  job = get_next_available_job
74
78
  if job.try(:running)
75
- @executor.post do
76
- Rails.application.executor.wrap do
77
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads { job.execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) }
78
- end
79
- end
79
+ @executor.post { Rails.application.reloader.wrap { job.execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) } }
80
80
  else
81
- @next_schedule_at = (job ? job.scheduled_at.to_f : Float::INFINITY)
81
+ next_schedule_at = (job ? job.scheduled_at.to_f : Float::INFINITY)
82
82
  end
83
83
  end
84
84
  job_notifications = []
@@ -92,7 +92,7 @@ module Skiplock
92
92
  job_notifications.each do |n|
93
93
  op, id, worker_id, job_class, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
94
94
  next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0
95
- @next_schedule_at = scheduled_at.to_f if scheduled_at.to_f < @next_schedule_at
95
+ next_schedule_at = scheduled_at.to_f if scheduled_at.to_f < next_schedule_at
96
96
  end
97
97
  end
98
98
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
@@ -105,11 +105,7 @@ module Skiplock
105
105
  Skiplock.logger.error(ex.backtrace.join("\n"))
106
106
  Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
107
107
  error = true
108
- t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
- while @running
110
- sleep(0.5)
111
- break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - t > 5
112
- end
108
+ wait(5)
113
109
  @last_exception = ex
114
110
  end
115
111
  sleep(0.3)
@@ -117,5 +113,13 @@ module Skiplock
117
113
  end
118
114
  @connection.exec_query('UNLISTEN *')
119
115
  end
116
+
117
+ def wait(timeout)
118
+ t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
119
+ while @running
120
+ sleep(0.5)
121
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - t > timeout
122
+ end
123
+ end
120
124
  end
121
125
  end
data/lib/skiplock.rb CHANGED
@@ -6,11 +6,12 @@ require 'skiplock/cron'
6
6
  require 'skiplock/extension'
7
7
  require 'skiplock/job'
8
8
  require 'skiplock/manager'
9
+ require 'skiplock/patch'
9
10
  require 'skiplock/worker'
10
11
  require 'skiplock/version'
11
12
 
12
13
  module Skiplock
13
- DEFAULT_CONFIG = { 'extensions' => false, 'logfile' => 'log/skiplock.log', 'loglevel' => 'info', 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 5, 'max_retries' => 20, 'notification' => 'custom', 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
14
+ DEFAULT_CONFIG = { 'extensions' => false, 'logfile' => 'skiplock.log', 'loglevel' => 'info', 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 10, 'max_retries' => 20, 'notification' => 'custom', 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
14
15
 
15
16
  def self.logger=(l)
16
17
  @logger = l
@@ -33,4 +34,5 @@ module Skiplock
33
34
  def self.table_name_prefix
34
35
  'skiplock.'
35
36
  end
36
- end
37
+ end
38
+ ActiveJob::Base.__send__(:include, Skiplock::Patch)
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.14
4
+ version: 1.0.18
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-07 00:00:00.000000000 Z
11
+ date: 2021-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -87,6 +87,7 @@ files:
87
87
  - lib/skiplock/extension.rb
88
88
  - lib/skiplock/job.rb
89
89
  - lib/skiplock/manager.rb
90
+ - lib/skiplock/patch.rb
90
91
  - lib/skiplock/version.rb
91
92
  - lib/skiplock/worker.rb
92
93
  homepage: https://github.com/vtt/skiplock