skiplock 1.0.12 → 1.0.16

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: 1acb8564f7930a8acbd316ae7ab85064aa3edb29232802d495f6c8261a2dfb72
4
- data.tar.gz: c7de58da66f483450fb34680b5e83d37865ce02b9f7e500de386a2ce30c17edb
3
+ metadata.gz: 7e9881d695e3f1a6241e3d86ca7844dff77ea3ba2b7431d70546a685dd1829a3
4
+ data.tar.gz: e9e2957e874d1a878f9302d4d08244830cf6abb7e9d9c6dc0970a2347344c0b7
5
5
  SHA512:
6
- metadata.gz: 69f54b48dd37b8814ef1bfb046945f4aaf13c996f35f9b297823f980804958d13a897dd1e8f5e28d6514d76867a4bac3a401aee2e682334cbabc2b52d4a36184
7
- data.tar.gz: f2fda19cff5a11f6d51c7d2223de5a1251bf6744b86364c82343dafe02807ad59f02b0ad6a4324002e49393f0f87de1356686cda347172e6c947e523fcfe8c92
6
+ metadata.gz: 7d5045bc4dcee7ebab3838f7970a0ed681cda2756736491df734a60eb04d0b4a2a4525073e52e59df43a6ef2cd1f11ade191002de1be53ad01b0169f8b21687d
7
+ data.tar.gz: 75b542459110cbc57ac2ea1dd19a9f3431f1c9d744871c64e0543cdd61a76d87ab3e5c946bef1cab43d2401b2970f17d3d7c35019ed8647a3d96a98f8f5e6a2e
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
 
@@ -53,7 +53,7 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
53
53
  min_threads: 1
54
54
  max_threads: 5
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,7 +67,7 @@ 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
73
  - **extensions** (*boolean*): enable or disable the class method extension. See `ClassMethod extension` for more details
@@ -84,7 +84,7 @@ 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
@@ -155,9 +155,9 @@ Outside the Rails application:
155
155
  # ...
156
156
  end
157
157
  ```
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.
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.
159
159
 
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.
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.
161
161
 
162
162
  ## Notification system
163
163
  `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:
@@ -187,6 +187,41 @@ If the `retry_on` block is not defined, then the built-in retry system of `skipl
187
187
  Subscription.skiplock(wait_until: Date.tomorrow.noon).charge(amount: 100)
188
188
  ```
189
189
 
190
+ ## Fault tolerant
191
+ `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` turned on), and failed or interrupted jobs are marked for retry.
192
+
193
+ 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.
194
+
195
+ This also protects in-progress jobs that were terminated abruptly during a graceful shutdown with timeout; they will be queued for retry.
196
+
197
+ ## Scalability
198
+ `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.
199
+
200
+ ## Statistics and counters
201
+ 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.
202
+
203
+ The `skiplock.counters` database table contains all historical 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.
204
+
205
+ - **completions**: numbers of jobs completed successfully
206
+ - **dispatches**: number of jobs dispatched for the first time (**retries** are not counted here)
207
+ - **expiries**: number of jobs exceeded `max_retry` and still failed to complete
208
+ - **failures**: number of jobs interrupted by graceful shutdown or errors (exceptions)
209
+ - **retries**: number of jobs dispatched for retrying
210
+
211
+ Code examples of gathering counters information:
212
+ - get counter information for today
213
+ ```ruby
214
+ Skiplock::Counter.where(day: Date.today).first
215
+ ```
216
+ - get total number of successfully completed jobs within the past 30 days
217
+ ```ruby
218
+ Skiplock::Counter.where("day >= ?", 30.days.ago).sum(:completions)
219
+ ```
220
+ - get total number of expired jobs
221
+ ```ruby
222
+ Skiplock::Counter.sum(:expiries)
223
+ ```
224
+
190
225
  ## Contributing
191
226
 
192
227
  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
- Skiplock::Manager.new(**options.merge(standalone: true))
33
+ Rails.application.config.skiplock.standalone(**options)
@@ -2,7 +2,7 @@ module ActiveJob
2
2
  module QueueAdapters
3
3
  class SkiplockAdapter
4
4
  def initialize
5
- Rails.application.config.after_initialize { Skiplock::Manager.new }
5
+ Rails.application.config.after_initialize { Rails.application.config.skiplock = Skiplock::Manager.new }
6
6
  end
7
7
 
8
8
  def enqueue(job)
@@ -43,24 +43,23 @@ 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 (record.executions > 0) 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
- PERFORM pg_notify('skiplock::jobs', CONCAT(TG_OP,',',record.id::TEXT,',',record.worker_id::TEXT,',',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));
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));
64
63
  RETURN NULL;
65
64
  END;
66
65
  $$ LANGUAGE plpgsql
@@ -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,7 +2,6 @@ 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')
data/lib/skiplock/job.rb CHANGED
@@ -1,38 +1,32 @@
1
1
  module Skiplock
2
2
  class Job < ActiveRecord::Base
3
3
  self.implicit_order_column = 'created_at'
4
+ attr_accessor :activejob_retry
5
+ belongs_to :worker, inverse_of: :jobs, required: false
4
6
 
5
- def self.dispatch(queues_order_query: nil, worker_id: nil, purge_completion: true, max_retries: 20)
6
- job = nil
7
- self.transaction do
8
- job = self.find_by_sql("SELECT id, scheduled_at FROM #{self.table_name} 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
9
- return (job ? job.scheduled_at.to_f : Float::INFINITY) if job.nil? || job.scheduled_at.to_f > Time.now.to_f
10
- job = Skiplock::Job.find_by_sql("UPDATE #{self.table_name} SET running = TRUE, worker_id = #{self.connection.quote(worker_id)}, updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
11
- end
12
- job.data ||= {}
13
- job.exception_executions ||= {}
14
- job_data = job.attributes.slice('job_class', 'queue_name', 'locale', 'timezone', 'priority', 'executions', 'exception_executions').merge('job_id' => job.id, 'enqueued_at' => job.updated_at, 'arguments' => (job.data['arguments'] || []))
15
- job.executions = (job.executions || 0) + 1
16
- Skiplock.logger.info "[Skiplock] Performing #{job.job_class} (#{job.id}) from queue '#{job.queue_name || 'default'}'..."
17
- Thread.current[:skiplock_dispatch_job] = job
18
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
- begin
20
- ActiveJob::Base.execute(job_data)
21
- rescue Exception => ex
22
- Skiplock.logger.error(ex)
23
- end
24
- unless ex
25
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
- job_name = job.job_class
27
- if job.job_class == 'Skiplock::Extension::ProxyJob'
28
- target, method_name = ::YAML.load(job.data['arguments'].first)
29
- job_name = "'#{target.name}.#{method_name}'"
7
+ # resynchronize jobs that could not commit to database and retry any abandoned jobs
8
+ def self.cleanup(purge_completion: true, max_retries: 20)
9
+ Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
10
+ Dir.glob('tmp/skiplock/*').each do |f|
11
+ job_from_db = self.find_by(id: File.basename(f), running: true)
12
+ disposed = true
13
+ if job_from_db
14
+ job, ex = YAML.load_file(f) rescue nil
15
+ disposed = job.dispose(ex, purge_completion: purge_completion, max_retries: max_retries) if job
30
16
  end
31
- Skiplock.logger.info "[Skiplock] Performed #{job_name} (#{job.id}) from queue '#{job.queue_name || 'default'}' in #{end_time - start_time} seconds"
17
+ (File.delete(f) rescue nil) if disposed
32
18
  end
33
- job.dispose(ex, purge_completion: purge_completion, max_retries: max_retries)
34
- ensure
35
- Thread.current[:skiplock_dispatch_job] = nil
19
+ self.where(running: true).where.not(worker_id: Worker.ids).update_all(running: false, worker_id: nil)
20
+ end
21
+
22
+ def self.dispatch(purge_completion: true, max_retries: 20)
23
+ job = nil
24
+ self.connection.transaction do
25
+ 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
26
+ return if job.nil? || job.scheduled_at.to_f > Time.now.to_f
27
+ job = self.find_by_sql("UPDATE skiplock.jobs SET running = TRUE, updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
28
+ end
29
+ self.dispatch(purge_completion: purge_completion, max_retries: max_retries) if job.execute(purge_completion: purge_completion, max_retries: max_retries)
36
30
  end
37
31
 
38
32
  def self.enqueue(activejob)
@@ -42,13 +36,14 @@ module Skiplock
42
36
  def self.enqueue_at(activejob, timestamp)
43
37
  timestamp = Time.at(timestamp) if timestamp
44
38
  if Thread.current[:skiplock_dispatch_job].try(:id) == activejob.job_id
45
- Thread.current[:skiplock_dispatch_job].exception_executions = activejob.exception_executions.merge('activejob_retry' => true)
39
+ Thread.current[:skiplock_dispatch_job].activejob_retry = true
46
40
  Thread.current[:skiplock_dispatch_job].executions = activejob.executions
41
+ Thread.current[:skiplock_dispatch_job].exception_executions = activejob.exception_executions
47
42
  Thread.current[:skiplock_dispatch_job].scheduled_at = timestamp
48
43
  Thread.current[:skiplock_dispatch_job]
49
44
  else
50
45
  serialize = activejob.serialize
51
- Job.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'] }, 'scheduled_at' => timestamp))
46
+ self.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'] }, 'scheduled_at' => timestamp))
52
47
  end
53
48
  end
54
49
 
@@ -57,47 +52,83 @@ module Skiplock
57
52
  end
58
53
 
59
54
  def dispose(ex, purge_completion: true, max_retries: 20)
60
- dup = self.dup
55
+ yaml = [self, ex].to_yaml
56
+ purging = false
61
57
  self.running = false
62
58
  self.worker_id = nil
63
- self.updated_at = (Time.now > self.updated_at ? Time.now : self.updated_at + 1)
59
+ self.updated_at = Time.now > self.updated_at ? Time.now : self.updated_at + 1 # in case of clock drifting
64
60
  if ex
65
- self.exception_executions["[#{ex.class.name}]"] = (self.exception_executions["[#{ex.class.name}]"] || 0) + 1 unless self.exception_executions.key?('activejob_retry')
66
- if self.executions >= max_retries || self.exception_executions.key?('activejob_retry')
61
+ self.exception_executions ||= {}
62
+ self.exception_executions["[#{ex.class.name}]"] = self.exception_executions["[#{ex.class.name}]"].to_i + 1 unless self.activejob_retry
63
+ if self.executions.to_i >= max_retries || self.activejob_retry
67
64
  self.expired_at = Time.now
68
65
  else
69
- self.scheduled_at = Time.now + (5 * 2**self.executions)
66
+ self.scheduled_at = Time.now + (5 * 2**self.executions.to_i)
70
67
  end
71
- self.save!
72
68
  Skiplock.on_errors.each { |p| p.call(ex) }
73
- elsif self.exception_executions.try(:key?, 'activejob_retry')
74
- self.save!
75
- elsif self.cron
76
- self.data ||= {}
77
- self.data['crons'] = (self.data['crons'] || 0) + 1
78
- self.data['last_cron_at'] = Time.now.utc.to_s
79
- next_cron_at = Cron.next_schedule_at(self.cron)
80
- if next_cron_at
81
- self.executions = nil
82
- self.exception_executions = nil
83
- self.scheduled_at = Time.at(next_cron_at)
84
- self.save!
85
- else
86
- Skiplock.logger.error "[Skiplock] ERROR: Invalid CRON '#{self.cron}' for Job #{self.job_class}"
87
- self.delete
69
+ elsif self.finished_at
70
+ if self.cron
71
+ self.data ||= {}
72
+ self.data['crons'] = (self.data['crons'] || 0) + 1
73
+ self.data['last_cron_at'] = Time.now.utc.to_s
74
+ next_cron_at = Cron.next_schedule_at(self.cron)
75
+ if next_cron_at
76
+ self.finished_at = nil
77
+ self.executions = nil
78
+ self.exception_executions = nil
79
+ self.scheduled_at = Time.at(next_cron_at)
80
+ else
81
+ Skiplock.logger.error("[Skiplock] ERROR: Invalid CRON '#{self.cron}' for Job #{self.job_class}") if Skiplock.logger
82
+ purging = true
83
+ end
84
+ elsif purge_completion
85
+ purging = true
88
86
  end
89
- elsif purge_completion
90
- self.delete
91
- else
92
- self.finished_at = Time.now
93
- self.exception_executions = nil
94
- self.save!
95
87
  end
96
- self
88
+ purging ? self.delete : self.update_columns(self.attributes.slice(*self.changes.keys))
97
89
  rescue Exception => e
98
- Skiplock.logger.error(e)
99
- File.write("tmp/skiplock/#{self.id}", [dup, ex].to_yaml)
90
+ File.write("tmp/skiplock/#{self.id}", yaml) rescue nil
91
+ if Skiplock.logger
92
+ Skiplock.logger.error(e.to_s)
93
+ Skiplock.logger.error(e.backtrace.join("\n"))
94
+ end
95
+ Skiplock.on_errors.each { |p| p.call(e) }
100
96
  nil
101
97
  end
98
+
99
+ def execute(purge_completion: true, max_retries: 20)
100
+ Skiplock.logger.info("[Skiplock] Performing #{self.job_class} (#{self.id}) from queue '#{self.queue_name || 'default'}'...") if Skiplock.logger
101
+ self.data ||= {}
102
+ self.exception_executions ||= {}
103
+ self.activejob_retry = false
104
+ 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'] || []))
105
+ self.executions = self.executions.to_i + 1
106
+ Thread.current[:skiplock_dispatch_job] = self
107
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
+ begin
109
+ ActiveJob::Base.execute(job_data)
110
+ self.finished_at = Time.now unless self.activejob_retry
111
+ rescue Exception => ex
112
+ end
113
+ if Skiplock.logger
114
+ if ex || self.activejob_retry
115
+ Skiplock.logger.error("[Skiplock] Job #{self.job_class} (#{self.id}) was interrupted by an exception#{ ' (rescued and retried by ActiveJob)' if self.activejob_retry }")
116
+ if ex
117
+ Skiplock.logger.error(ex.to_s)
118
+ Skiplock.logger.error(ex.backtrace.join("\n"))
119
+ end
120
+ else
121
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
122
+ job_name = self.job_class
123
+ if self.job_class == 'Skiplock::Extension::ProxyJob'
124
+ target, method_name = ::YAML.load(self.data['arguments'].first)
125
+ job_name = "'#{target.name}.#{method_name}'"
126
+ end
127
+ Skiplock.logger.info "[Skiplock] Performed #{job_name} (#{self.id}) from queue '#{self.queue_name || 'default'}' in #{end_time - start_time} seconds"
128
+ end
129
+ end
130
+ ensure
131
+ self.dispose(ex, purge_completion: purge_completion, max_retries: max_retries)
132
+ end
102
133
  end
103
134
  end
@@ -6,30 +6,59 @@ module Skiplock
6
6
  @config.symbolize_keys!
7
7
  @config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
8
8
  @config.merge!(config)
9
- Module.__send__(:include, Skiplock::Extension) if @config[:extensions] == true
10
- return unless @config[:standalone] || (caller.any?{ |l| l =~ %r{/rack/} } && (@config[:workers] == 0 || Rails.env.development?))
11
9
  @config[:hostname] = `hostname -f`.strip
12
- do_config
13
- banner if @config[:standalone]
10
+ configure
11
+ setup_logger
12
+ Module.__send__(:include, Skiplock::Extension) if @config[:extensions] == true
13
+ if (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0)
14
+ cleanup_workers
15
+ @worker = create_worker
16
+ @worker.start(**@config)
17
+ at_exit { @worker.shutdown }
18
+ end
19
+ rescue Exception => ex
20
+ @logger.error(ex.to_s)
21
+ @logger.error(ex.backtrace.join("\n"))
22
+ end
23
+
24
+ def standalone(**options)
25
+ @config.merge!(options)
26
+ Rails.logger.reopen('/dev/null')
27
+ Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
28
+ @config[:workers] = 1 if @config[:workers] <= 0
29
+ @config[:standalone] = true
30
+ banner
14
31
  cleanup_workers
15
- create_worker
16
- ActiveJob::Base.logger = nil
17
- if @config[:standalone]
18
- standalone
19
- else
20
- dispatcher = Dispatcher.new(worker: @worker, **@config)
21
- thread = dispatcher.run
22
- at_exit do
23
- dispatcher.shutdown
24
- thread.join(@config[:graceful_shutdown])
25
- @worker.delete
32
+ @worker = create_worker
33
+ @parent_id = Process.pid
34
+ @shutdown = false
35
+ Signal.trap('INT') { @shutdown = true }
36
+ Signal.trap('TERM') { @shutdown = true }
37
+ Signal.trap('HUP') { setup_logger }
38
+ (@config[:workers] - 1).times do |n|
39
+ fork do
40
+ sleep 1
41
+ worker = create_worker(master: false)
42
+ worker.start(worker_num: n + 1, **@config)
43
+ loop do
44
+ sleep 0.5
45
+ break if @shutdown || Process.ppid != @parent_id
46
+ end
47
+ worker.shutdown
26
48
  end
27
49
  end
28
- rescue Exception => ex
29
- @logger.error(ex)
50
+ @worker.start(**@config)
51
+ loop do
52
+ sleep 0.5
53
+ break if @shutdown
54
+ end
55
+ @logger.info "[Skiplock] Terminating signal... Waiting for jobs to finish (up to #{@config[:graceful_shutdown]} seconds)..." if @config[:graceful_shutdown]
56
+ Process.waitall
57
+ @worker.shutdown
58
+ @logger.info "[Skiplock] Shutdown completed."
30
59
  end
31
60
 
32
- private
61
+ private
33
62
 
34
63
  def banner
35
64
  title = "Skiplock #{Skiplock::VERSION} (Rails #{Rails::VERSION::STRING} | Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
@@ -53,25 +82,22 @@ module Skiplock
53
82
  end
54
83
 
55
84
  def cleanup_workers
85
+ Rails.application.eager_load! if Rails.env.development?
56
86
  delete_ids = []
57
87
  Worker.where(hostname: @config[:hostname]).each do |worker|
58
88
  sid = Process.getsid(worker.pid) rescue nil
59
- delete_ids << worker.id if worker.sid != sid || worker.updated_at < 30.minutes.ago
60
- end
61
- if delete_ids.count > 0
62
- Job.where(running: true, worker_id: delete_ids).update_all(running: false, worker_id: nil)
63
- Worker.where(id: delete_ids).delete_all
89
+ delete_ids << worker.id if worker.sid != sid || worker.updated_at < 10.minutes.ago
64
90
  end
91
+ Worker.where(id: delete_ids).delete_all if delete_ids.count > 0
65
92
  end
66
93
 
67
- def create_worker(pid: Process.pid, sid: Process.getsid(), master: true)
68
- @worker = Worker.create!(pid: pid, sid: sid, master: master, hostname: @config[:hostname], capacity: @config[:max_threads])
94
+ def create_worker(master: true)
95
+ Worker.create!(pid: Process.pid, sid: Process.getsid(), master: master, hostname: @config[:hostname], capacity: @config[:max_threads])
69
96
  rescue
70
- @worker = Worker.create!(pid: pid, sid: sid, master: false, hostname: @config[:hostname], capacity: @config[:max_threads])
97
+ Worker.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: @config[:hostname], capacity: @config[:max_threads])
71
98
  end
72
99
 
73
- def do_config
74
- @config[:loglevel] = 'info' unless ['debug','info','warn','error','fatal','unknown'].include?(@config[:loglevel].to_s)
100
+ def configure
75
101
  @config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
76
102
  @config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
77
103
  @config[:max_retries] = 20 if @config[:max_retries] > 20
@@ -80,19 +106,6 @@ module Skiplock
80
106
  @config[:max_threads] = 20 if @config[:max_threads] > 20
81
107
  @config[:min_threads] = 0 if @config[:min_threads] < 0
82
108
  @config[:workers] = 0 if @config[:workers] < 0
83
- @config[:workers] = 1 if @config[:standalone] && @config[:workers] <= 0
84
- @logger = ActiveSupport::Logger.new(STDOUT)
85
- @logger.level = @config[:loglevel].to_sym
86
- Skiplock.logger = @logger
87
- raise "Cannot create logfile '#{@config[:logfile]}'" if @config[:logfile] && !File.writable?(File.dirname(@config[:logfile]))
88
- @config[:logfile] = nil if @config[:logfile].to_s.length == 0
89
- if @config[:logfile]
90
- @logger.extend(ActiveSupport::Logger.broadcast(::Logger.new(@config[:logfile])))
91
- if @config[:standalone]
92
- Rails.logger.reopen('/dev/null')
93
- Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
94
- end
95
- end
96
109
  @config[:queues].values.each { |v| raise 'Queue value must be an integer' unless v.is_a?(Integer) } if @config[:queues].is_a?(Hash)
97
110
  if @config[:notification] == 'auto'
98
111
  if defined?(Airbrake)
@@ -127,39 +140,19 @@ module Skiplock
127
140
  Skiplock.on_errors.freeze unless Skiplock.on_errors.frozen?
128
141
  end
129
142
 
130
- def standalone
131
- parent_id = Process.pid
132
- shutdown = false
133
- Signal.trap("INT") { shutdown = true }
134
- Signal.trap("TERM") { shutdown = true }
135
- (@config[:workers] - 1).times do |n|
136
- fork do
137
- sleep 1
138
- worker = create_worker(master: false)
139
- dispatcher = Dispatcher.new(worker: worker, worker_num: n + 1, **@config)
140
- thread = dispatcher.run
141
- loop do
142
- sleep 0.5
143
- break if shutdown || Process.ppid != parent_id
144
- end
145
- dispatcher.shutdown
146
- thread.join(@config[:graceful_shutdown])
147
- worker.delete
148
- exit
149
- end
150
- end
151
- dispatcher = Dispatcher.new(worker: @worker, **@config)
152
- thread = dispatcher.run
153
- loop do
154
- sleep 0.5
155
- break if shutdown
143
+ def setup_logger
144
+ @config[:loglevel] = 'info' unless ['debug','info','warn','error','fatal','unknown'].include?(@config[:loglevel].to_s)
145
+ @logger = ActiveSupport::Logger.new(STDOUT)
146
+ @logger.level = @config[:loglevel].to_sym
147
+ Skiplock.logger = @logger
148
+ if @config[:logfile].to_s.length > 0
149
+ @logger.extend(ActiveSupport::Logger.broadcast(::Logger.new(File.join(Rails.root, 'log', @config[:logfile].to_s), 'daily')))
150
+ ActiveJob::Base.logger = nil
156
151
  end
157
- @logger.info "[Skiplock] Terminating signal... Waiting for jobs to finish (up to #{@config[:graceful_shutdown]} seconds)..." if @config[:graceful_shutdown]
158
- Process.waitall
159
- dispatcher.shutdown
160
- thread.join(@config[:graceful_shutdown])
161
- @worker.delete
162
- @logger.info "[Skiplock] Shutdown completed."
152
+ rescue Exception => ex
153
+ puts "Exception with logger: #{ex.to_s}"
154
+ puts ex.backtrace.join("\n")
155
+ Skiplock.on_errors.each { |p| p.call(ex) }
163
156
  end
164
157
  end
165
158
  end
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.12'
2
+ VERSION = Version = '1.0.16'
3
3
  end
4
4
 
@@ -1,5 +1,110 @@
1
1
  module Skiplock
2
2
  class Worker < ActiveRecord::Base
3
3
  self.implicit_order_column = 'created_at'
4
+ has_many :jobs, inverse_of: :worker
5
+
6
+ def start(worker_num: 0, **config)
7
+ @config = config
8
+ @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
9
+ @next_schedule_at = Time.now.to_f
10
+ @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)
11
+ if self.master
12
+ Job.cleanup(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries])
13
+ Cron.setup
14
+ end
15
+ @running = true
16
+ Process.setproctitle("skiplock-#{self.master ? 'master[0]' : 'worker[' + worker_num.to_s + ']'}") if @config[:standalone]
17
+ @executor.post { run }
18
+ end
19
+
20
+ def shutdown
21
+ @running = false
22
+ @executor.shutdown
23
+ @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
24
+ self.delete
25
+ end
26
+
27
+ private
28
+
29
+ def get_next_available_job
30
+ @connection.transaction do
31
+ 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
32
+ if job && job.scheduled_at.to_f <= Time.now.to_f
33
+ job = Job.find_by_sql("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
34
+ end
35
+ job
36
+ end
37
+ end
38
+
39
+ def run
40
+ error = false
41
+ listen = false
42
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ while @running
44
+ Rails.application.reloader.wrap do
45
+ begin
46
+ unless listen
47
+ @connection = self.class.connection
48
+ @connection.exec_query('LISTEN "skiplock::jobs"')
49
+ listen = true
50
+ end
51
+ if error
52
+ unless @connection.active?
53
+ @connection.reconnect!
54
+ sleep(0.5)
55
+ @connection.exec_query('LISTEN "skiplock::jobs"')
56
+ @next_schedule_at = Time.now.to_f
57
+ end
58
+ Job.cleanup if self.master
59
+ error = false
60
+ end
61
+ if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
62
+ job = get_next_available_job
63
+ if job.try(:running)
64
+ @executor.post { Rails.application.reloader.wrap { job.execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) } }
65
+ else
66
+ @next_schedule_at = (job ? job.scheduled_at.to_f : Float::INFINITY)
67
+ end
68
+ end
69
+ job_notifications = []
70
+ @connection.raw_connection.wait_for_notify(0.4) do |channel, pid, payload|
71
+ job_notifications << payload if payload
72
+ loop do
73
+ payload = @connection.raw_connection.notifies
74
+ break unless @running && payload
75
+ job_notifications << payload[:extra]
76
+ end
77
+ job_notifications.each do |n|
78
+ op, id, worker_id, job_class, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
79
+ next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0
80
+ @next_schedule_at = scheduled_at.to_f if scheduled_at.to_f < @next_schedule_at
81
+ end
82
+ end
83
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
84
+ self.touch
85
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
+ end
87
+ rescue Exception => ex
88
+ # most likely error with database connection
89
+ Skiplock.logger.error(ex.to_s)
90
+ Skiplock.logger.error(ex.backtrace.join("\n"))
91
+ Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
92
+ error = true
93
+ wait(5)
94
+ @last_exception = ex
95
+ end
96
+ sleep(0.3)
97
+ end
98
+ end
99
+ @connection.exec_query('UNLISTEN *')
100
+ end
101
+
102
+ def wait(timeout)
103
+ t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
104
+ while @running
105
+ sleep(0.5)
106
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - t > timeout
107
+ end
108
+ end
4
109
  end
5
110
  end
data/lib/skiplock.rb CHANGED
@@ -3,7 +3,6 @@ require 'active_job/queue_adapters/skiplock_adapter'
3
3
  require 'active_record'
4
4
  require 'skiplock/counter'
5
5
  require 'skiplock/cron'
6
- require 'skiplock/dispatcher'
7
6
  require 'skiplock/extension'
8
7
  require 'skiplock/job'
9
8
  require 'skiplock/manager'
@@ -11,7 +10,7 @@ require 'skiplock/worker'
11
10
  require 'skiplock/version'
12
11
 
13
12
  module Skiplock
14
- 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
13
+ DEFAULT_CONFIG = { 'extensions' => false, 'logfile' => '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
15
14
 
16
15
  def self.logger=(l)
17
16
  @logger = l
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.12
4
+ version: 1.0.16
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-08-30 00:00:00.000000000 Z
11
+ date: 2021-09-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -84,7 +84,6 @@ files:
84
84
  - lib/skiplock.rb
85
85
  - lib/skiplock/counter.rb
86
86
  - lib/skiplock/cron.rb
87
- - lib/skiplock/dispatcher.rb
88
87
  - lib/skiplock/extension.rb
89
88
  - lib/skiplock/job.rb
90
89
  - lib/skiplock/manager.rb
@@ -1,116 +0,0 @@
1
- module Skiplock
2
- class Dispatcher
3
- def initialize(worker:, worker_num: nil, **config)
4
- @config = config
5
- @worker = worker
6
- @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
7
- @executor = Concurrent::ThreadPoolExecutor.new(min_threads: @config[:min_threads], max_threads: @config[:max_threads], max_queue: @config[:max_threads], idletime: 60, auto_terminate: true, fallback_policy: :discard)
8
- @last_dispatch_at = 0
9
- @next_schedule_at = Time.now.to_f
10
- Process.setproctitle("skiplock-#{@worker.master ? 'master[0]' : 'worker[' + worker_num.to_s + ']'}") if @config[:standalone]
11
- end
12
-
13
- def run
14
- @running = true
15
- Thread.new do
16
- ActiveRecord::Base.connection_pool.with_connection do |connection|
17
- connection.exec_query('LISTEN "skiplock::jobs"')
18
- if @worker.master
19
- Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
20
- check_sync_errors
21
- Cron.setup
22
- end
23
- error = false
24
- timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
- while @running
26
- begin
27
- if error
28
- unless connection.active?
29
- connection.reconnect!
30
- sleep(0.5)
31
- connection.exec_query('LISTEN "skiplock::jobs"')
32
- @next_schedule_at = Time.now.to_f
33
- end
34
- check_sync_errors
35
- error = false
36
- end
37
- job_notifications = []
38
- connection.raw_connection.wait_for_notify(0.1) do |channel, pid, payload|
39
- job_notifications << payload if payload
40
- loop do
41
- payload = connection.raw_connection.notifies
42
- break unless @running && payload
43
- job_notifications << payload[:extra]
44
- end
45
- job_notifications.each do |n|
46
- op, id, worker_id, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
47
- next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0 || scheduled_at.to_f < @last_dispatch_at
48
- if scheduled_at.to_f <= Time.now.to_f
49
- @next_schedule_at = Time.now.to_f
50
- elsif scheduled_at.to_f < @next_schedule_at
51
- @next_schedule_at = scheduled_at.to_f
52
- end
53
- end
54
- end
55
- if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
56
- @executor.post { do_work }
57
- end
58
- if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
59
- @worker.touch
60
- timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
- end
62
- rescue Exception => ex
63
- # most likely error with database connection
64
- Skiplock.logger.error(ex)
65
- Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
66
- error = true
67
- t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
- while @running
69
- sleep(0.5)
70
- break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - t > 5
71
- end
72
- @last_exception = ex
73
- end
74
- sleep(0.2)
75
- end
76
- connection.exec_query('UNLISTEN *')
77
- @executor.shutdown
78
- @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
79
- end
80
- end
81
- end
82
-
83
- def shutdown
84
- @running = false
85
- end
86
-
87
- private
88
-
89
- def check_sync_errors
90
- # get performed jobs that could not sync with database
91
- Dir.glob('tmp/skiplock/*').each do |f|
92
- job_from_db = Job.find_by(id: File.basename(f), running: true)
93
- disposed = true
94
- if job_from_db
95
- job, ex = YAML.load_file(f) rescue nil
96
- disposed = job.dispose(ex, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries])
97
- end
98
- File.delete(f) if disposed
99
- end
100
- end
101
-
102
- def do_work
103
- while @running
104
- @last_dispatch_at = Time.now.to_f - 1 # 1 second allowance for time drift
105
- result = Job.dispatch(queues_order_query: @queues_order_query, worker_id: @worker.id, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries])
106
- next if result.is_a?(Job) && Time.now.to_f >= @next_schedule_at
107
- @next_schedule_at = result if result.is_a?(Float)
108
- break
109
- end
110
- rescue Exception => ex
111
- Skiplock.logger.error(ex)
112
- Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
113
- @last_exception = ex
114
- end
115
- end
116
- end