skiplock 1.0.9 → 1.0.10

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: 7a975a3bb22318c6203edd59428a93d6a4abfa5ea1382f35abd774760467dc23
4
- data.tar.gz: 79b787d23af0db0188aebf1b0361649d906d5f0cca4488d7cfcb3f61927d4781
3
+ metadata.gz: 681b41b73f6ddf95620515f3d83454ba1f623d57740248bf84041829c225318d
4
+ data.tar.gz: 0433b2e635174ae052b67d7502e4b2477c54736f79ec0acf0ad369a1be976256
5
5
  SHA512:
6
- metadata.gz: 407469700b1fab6c4ea702b83df0571fd46322ad32c8c8fe8ffc0269e5e93cd9f774b8bd4658021320732ca4de6d7609a64430cbc26bddc14c9f14cf184f92e3
7
- data.tar.gz: 69a5fe7f5c88f21585a4b581d34f44183cb12f25bc0df6e7982497fb63c088316a251cc55c4e712b3cad799ec9aa758c8045d911fbeda393267ed39bc694e9a5
6
+ metadata.gz: 6860b6cff70a8881277fd0ce7d2d76d211723e8c1974a1e2b70db7ea31b05268af6d38b68f2437cc9144d0d896751de205e21890219d3664b0f5ae128e1c6ab0
7
+ data.tar.gz: ddf11fbb1b030dc437254f87142127fd135bacbb84301d03fbcb14e3029bc05736e9c02b1d08b6be8f2d6b90e3fbfdf8eff568c63c8d8c9b451c730a1a125b39
data/README.md CHANGED
@@ -48,43 +48,49 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
48
48
  ```
49
49
  2. `Skiplock` configuration
50
50
  ```yaml
51
- # config/skiplock.yml
51
+ # config/skiplock.yml (default settings)
52
52
  ---
53
- logging: timestamp
53
+ extensions: false
54
+ logging: true
54
55
  min_threads: 1
55
56
  max_threads: 5
56
57
  max_retries: 20
57
- notification: auto
58
+ notification: none
58
59
  purge_completion: true
59
60
  queues:
60
61
  default: 200
61
- mailers: 100
62
+ mailers: 999
62
63
  workers: 0
63
64
  ```
64
65
  Available configuration options are:
65
- - **logging** (*enumeration*): sets the logging capability to **true** or **false**; setting to **timestamp** will enable logging with timestamps. The log files are: log/skiplock.log and log/skiplock.error.log
66
+ - **extensions** (*boolean*): enable or disable the class method extension. See `ClassMethod extension` for more details
67
+ - **logging** (*boolean*): enable or disable file logging capability; the log file is stored at log/skiplock.log
66
68
  - **min_threads** (*integer*): sets minimum number of threads staying idle
67
69
  - **max_threads** (*integer*): sets the maximum number of threads allowed to run jobs
68
- - **max_retries** (*integer*): sets the maximum attempt a job will be retrying before it is marked expired (see Retry System for more details)
69
- - **notification** (*enumeration*): sets the library to be used for notifying errors and exceptions (`auto, airbrake, bugsnag, exception_notification, false`)
70
+ - **max_retries** (*integer*): sets the maximum attempt a job will be retrying before it is marked expired. See `Retry System` for more details
71
+ - **notification** (*enumeration*): sets the library to be used for notifying errors and exceptions (`auto, airbrake, bugsnag, exception_notification, none`). Using `auto` will attempt to detect available gems in the application
70
72
  - **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)
71
73
  - **queues** (*hash*): defines the set of queues with priorities; lower priority takes precedence
72
74
  - **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**
73
-
74
- #### Async mode
75
- When **workers** is set to **0** then the jobs will be performed in the web server process using separate threads. If using multi-worker cluster mode web server like Puma, then it should be configured as below:
76
- ```ruby
77
- # config/puma.rb
78
- # ...
79
- on_worker_fork do |worker_index|
80
- Skiplock::Manager.shutdown if worker_index == 1
81
- end
82
75
 
83
- after_worker_fork do |worker_index|
84
- # restarts skiplock after all Puma workers have been started
85
- Skiplock::Manager.start(restart: true) if defined?(Skiplock) && worker_index + 1 == @options[:workers]
86
- end
76
+ #### **Async mode**
77
+ When **workers** is set to **0** then the jobs will be performed in the web server process using separate threads. If using multi-worker cluster mode web server like Puma, then all the Puma workers will also be able to perform `Skiplock` jobs.
78
+
79
+ #### **Standalone mode**
80
+ `Skiplock` standalone mode can be launched by using the `skiplock` executable; command line options can be provided to override the `Skiplock` configuration file.
81
+ ```
82
+ $ bundle exec skiplock -h
83
+ Usage: skiplock [options]
84
+ -e, --environment STRING Rails environment
85
+ -l, --logging STRING Possible values: true, false, timestamp
86
+ -s, --graceful-shutdown NUM Number of seconds to wait for graceful shutdown
87
+ -r, --max-retries NUM Number of maxixum retries
88
+ -t, --max-threads NUM Number of maximum threads
89
+ -T, --min-threads NUM Number of minimum threads
90
+ -w, --workers NUM Number of workers
91
+ -h, --help Show this message
87
92
  ```
93
+
88
94
  ## Usage
89
95
  Inside the Rails application:
90
96
  - queue your job
@@ -159,10 +165,25 @@ If the `retry_on` block is not defined, then the built-in retry system of `skipl
159
165
  if ex.backtrace != previous.try(:backtrace)
160
166
  # sends custom email on new exceptions only
161
167
  # the same repeated exceptions will only be sent once to avoid SPAM
168
+ # NOTE: exceptions generated from Job perform method executions will not provide 'previous' exceptions
162
169
  end
163
170
  end
164
171
  # supports multiple on_error callbacks
165
172
  ```
173
+ ## ClassMethod extension
174
+ `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`.
175
+ - Queue class method `generate_thumbnails` of class `Image` as background job to run as soon as possible
176
+ ```ruby
177
+ Image.skiplock.generate_thumbnails(height: 100, ratio: true)
178
+ ```
179
+ - Queue class method `cleanup` of class `Session` as background job on queue `maintenance` to run after 5 minutes
180
+ ```ruby
181
+ Session.skiplock(wait: 5.minutes, queue: 'maintenance').cleanup
182
+ ```
183
+ - Queue class method `charge` of class `Subscription` as background job to run tomorrow at noon
184
+ ```ruby
185
+ Subscription.skiplock(wait_until: Date.tomorrow.noon).charge(amount: 100)
186
+ ```
166
187
 
167
188
  ## Contributing
168
189
 
data/bin/skiplock CHANGED
@@ -6,9 +6,10 @@ begin
6
6
  opts.banner = "Usage: #{File.basename($0)} [options]"
7
7
  opts.on('-e', '--environment STRING', String, 'Rails environment')
8
8
  opts.on('-l', '--logging STRING', String, 'Possible values: true, false, timestamp')
9
- opts.on('-r', '--max_retries NUM', Integer, 'Number of maxixum retries')
10
- opts.on('-t', '--max_threads NUM', Integer, 'Number of maximum threads')
11
- opts.on('-T', '--min_threads NUM', Integer, 'Number of minimum threads')
9
+ opts.on('-s', '--graceful-shutdown NUM', Integer, 'Number of seconds to wait for graceful shutdown')
10
+ opts.on('-r', '--max-retries NUM', Integer, 'Number of maxixum retries')
11
+ opts.on('-t', '--max-threads NUM', Integer, 'Number of maximum threads')
12
+ opts.on('-T', '--min-threads NUM', Integer, 'Number of minimum threads')
12
13
  opts.on('-w', '--workers NUM', Integer, 'Number of workers')
13
14
  opts.on_tail('-h', '--help', 'Show this message') do
14
15
  exit
@@ -20,7 +21,8 @@ rescue Exception => e
20
21
  puts op
21
22
  exit
22
23
  end
24
+ options.transform_keys! { |k| k.to_s.gsub('-', '_').to_sym }
23
25
  env = options.delete(:environment)
24
26
  ENV['RAILS_ENV'] = env if env
25
27
  require File.expand_path("config/environment.rb")
26
- Skiplock::Manager.start(standalone: true, **options)
28
+ Skiplock::Manager.new(options.merge(standalone: true))
@@ -2,7 +2,7 @@ module ActiveJob
2
2
  module QueueAdapters
3
3
  class SkiplockAdapter
4
4
  def initialize
5
- Skiplock::Manager.start
5
+ Rails.application.config.after_initialize { Skiplock::Manager.new }
6
6
  end
7
7
 
8
8
  def enqueue(job)
@@ -12,7 +12,7 @@ module Skiplock
12
12
  end
13
13
 
14
14
  def create_config_file
15
- create_file 'config/skiplock.yml', Skiplock::Settings.to_yaml
15
+ create_file 'config/skiplock.yml', Skiplock::DEFAULT_CONFIG.to_yaml
16
16
  end
17
17
 
18
18
  def create_migration_file
@@ -27,10 +27,11 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
27
27
  t.timestamps null: false, default: -> { 'now()' }
28
28
  end
29
29
  create_table 'skiplock.workers', id: :uuid do |t|
30
- t.integer :pid, null: false, index: true
31
- t.integer :ppid, index: true
30
+ t.integer :pid, null: false
31
+ t.integer :sid, null: false
32
32
  t.integer :capacity, null: false
33
33
  t.string :hostname, null: false, index: true
34
+ t.boolean :master, null: false, default: false, index: true
34
35
  t.jsonb :data
35
36
  t.timestamps null: false, index: true, default: -> { 'now()' }
36
37
  end
@@ -74,7 +75,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
74
75
  ELSE
75
76
  record = NEW;
76
77
  END IF;
77
- PERFORM pg_notify('skiplock::workers', CONCAT(TG_OP,',',record.id::TEXT,',',record.hostname,',',record.capacity,',',record.pid,',',record.ppid));
78
+ PERFORM pg_notify('skiplock::workers', CONCAT(TG_OP,',',record.id::TEXT,',',record.hostname,',',record.master::TEXT,',',record.capacity,',',record.pid,',',record.sid,',',CAST(EXTRACT(EPOCH FROM record.created_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.updated_at) AS FLOAT)::TEXT));
78
79
  RETURN NULL;
79
80
  END;
80
81
  $$ LANGUAGE plpgsql;
@@ -85,6 +86,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
85
86
  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
87
  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
88
  execute "CREATE UNIQUE INDEX jobs_unique_cron_index ON skiplock.jobs (job_class) WHERE cron IS NOT NULL"
89
+ execute "CREATE UNIQUE INDEX workers_unique_master_index ON skiplock.workers(hostname) WHERE master = 't'"
88
90
  end
89
91
 
90
92
  def down
data/lib/skiplock/cron.rb CHANGED
@@ -6,13 +6,13 @@ module Skiplock
6
6
  ActiveJob::Base.descendants.each do |j|
7
7
  next unless j.const_defined?('CRON')
8
8
  cron = j.const_get('CRON')
9
- job = Job.find_by('job_class = ? AND cron IS NOT NULL', j.name) || Job.new(job_class: j.name, cron: cron)
9
+ 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)
10
10
  time = self.next_schedule_at(cron)
11
11
  if time
12
12
  job.cron = cron
13
13
  job.running = false
14
14
  job.scheduled_at = Time.at(time)
15
- job.save!
15
+ job.save
16
16
  cronjobs << j.name
17
17
  end
18
18
  end
@@ -1,115 +1,88 @@
1
1
  module Skiplock
2
2
  class Dispatcher
3
- def initialize(master: true, worker_num: nil, worker_pids: [])
4
- @queues_order_query = Settings['queues'].map { |q,v| "WHEN queue_name = '#{q}' THEN #{v}" }.join(' ') if Settings['queues'].is_a?(Hash) && Settings['queues'].count > 0
5
- @executor = Concurrent::ThreadPoolExecutor.new(min_threads: Settings['min_threads'], max_threads: Settings['max_threads'], max_queue: Settings['max_threads'], idletime: 60, auto_terminate: true, fallback_policy: :discard)
6
- @master = master
7
- if @master
8
- @worker_pids = worker_pids + [ Process.pid ]
9
- else
10
- @worker_num = worker_num
11
- end
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)
12
8
  @last_dispatch_at = 0
13
9
  @next_schedule_at = Time.now.to_f
14
- @running = true
10
+ Process.setproctitle("skiplock-#{@worker.master ? 'master[0]' : 'worker[' + @worker_num.to_s + ']'}") if @config[:standalone]
15
11
  end
16
12
 
17
13
  def run
14
+ @running = true
18
15
  Thread.new do
19
- Rails.application.reloader.wrap do
20
- sleep(1) while @running && !Rails.application.initialized?
21
- Rails.application.eager_load!
22
- Process.setproctitle("skiplock-#{@master ? 'master[0]' : 'worker[' + @worker_num.to_s + ']'}") if Settings['workers'] > 0 && !Rails.env.development?
23
- ActiveRecord::Base.connection_pool.with_connection do |connection|
24
- connection.exec_query('LISTEN "skiplock::jobs"')
25
- hostname = `hostname -f`.strip
26
- @worker = Worker.create!(pid: Process.pid, ppid: (@master ? nil : Process.ppid), capacity: Settings['max_threads'], hostname: hostname)
27
- if @master
28
- connection.exec_query('LISTEN "skiplock::workers"')
29
- Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
30
- check_sync_errors
31
- # get dead worker ids
32
- dead_worker_ids = Worker.where(hostname: hostname).where.not(pid: @worker_pids).ids
33
- if dead_worker_ids.count > 0
34
- # reset orphaned jobs of the dead worker ids for retry
35
- Job.where(running: true).where(worker_id: dead_worker_ids).update_all(running: false, worker_id: nil)
36
- # remove dead workers
37
- Worker.where(id: dead_worker_ids).delete_all
16
+ ActiveRecord::Base.connection_pool.with_connection do |connection|
17
+ connection.exec_query('LISTEN "skiplock::jobs"')
18
+ if @worker.master
19
+ Rails.application.eager_load! if Rails.env.development?
20
+ Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
21
+ check_sync_errors
22
+ Cron.setup
23
+ end
24
+ error = false
25
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
+ while @running
27
+ begin
28
+ if error
29
+ unless connection.active?
30
+ connection.reconnect!
31
+ sleep(0.5)
32
+ connection.exec_query('LISTEN "skiplock::jobs"')
33
+ @next_schedule_at = Time.now.to_f
34
+ end
35
+ check_sync_errors
36
+ error = false
38
37
  end
39
- # reset retries schedules on startup
40
- Job.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)
41
- Cron.setup
42
- end
43
- error = false
44
- while @running
45
- begin
46
- if error
47
- unless connection.active?
48
- connection.reconnect!
49
- sleep(0.5)
50
- connection.exec_query('LISTEN "skiplock::jobs"')
51
- connection.exec_query('LISTEN "skiplock::workers"') if @master
52
- @next_schedule_at = Time.now.to_f
53
- end
54
- check_sync_errors
55
- error = false
38
+ job_notifications = []
39
+ connection.raw_connection.wait_for_notify(0.1) do |channel, pid, payload|
40
+ job_notifications << payload if payload
41
+ loop do
42
+ payload = connection.raw_connection.notifies
43
+ break unless @running && payload
44
+ job_notifications << payload[:extra]
56
45
  end
57
- notifications = { 'skiplock::jobs' => [], 'skiplock::workers' => [] }
58
- connection.raw_connection.wait_for_notify(0.1) do |channel, pid, payload|
59
- notifications[channel] << payload if payload
60
- loop do
61
- payload = connection.raw_connection.notifies
62
- break unless @running && payload
63
- notifications[payload[:relname]] << payload[:extra]
64
- end
65
- notifications['skiplock::jobs'].each do |n|
66
- op, id, worker_id, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
67
- if @master
68
- # TODO: report job status to action cable
69
- end
70
- next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0 || scheduled_at.to_f < @last_dispatch_at
71
- if scheduled_at.to_f < Time.now.to_f
72
- @next_schedule_at = Time.now.to_f
73
- elsif scheduled_at.to_f < @next_schedule_at
74
- @next_schedule_at = scheduled_at.to_f
75
- end
76
- end
77
- if @master
78
- # TODO: report worker status to action cable
79
- notifications['skiplock::workers'].each do |n|
80
- end
46
+ job_notifications.each do |n|
47
+ op, id, worker_id, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
48
+ next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0 || scheduled_at.to_f < @last_dispatch_at
49
+ if scheduled_at.to_f <= Time.now.to_f
50
+ @next_schedule_at = Time.now.to_f
51
+ elsif scheduled_at.to_f < @next_schedule_at
52
+ @next_schedule_at = scheduled_at.to_f
81
53
  end
82
54
  end
83
- if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
84
- @executor.post { do_work }
85
- end
86
- rescue Exception => ex
87
- # most likely error with database connection
88
- STDERR.puts ex.message
89
- STDERR.puts ex.backtrace
90
- Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
91
- error = true
92
- t = Time.now
93
- while @running
94
- sleep(0.5)
95
- break if Time.now - t > 5
96
- end
97
- @last_exception = ex
98
55
  end
99
- sleep(0.2)
56
+ if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
57
+ @executor.post { do_work }
58
+ end
59
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
60
+ @worker.touch
61
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
62
+ end
63
+ rescue Exception => ex
64
+ # most likely error with database connection
65
+ Skiplock.logger.error(ex)
66
+ Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
67
+ error = true
68
+ t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
+ while @running
70
+ sleep(0.5)
71
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - t > 5
72
+ end
73
+ @last_exception = ex
100
74
  end
101
- connection.exec_query('UNLISTEN *')
102
- @executor.shutdown
103
- @executor.wait_for_termination if @wait
104
- @worker.delete if @worker
75
+ sleep(0.2)
105
76
  end
77
+ connection.exec_query('UNLISTEN *')
78
+ @executor.shutdown
79
+ @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
106
80
  end
107
81
  end
108
82
  end
109
83
 
110
- def shutdown(wait: true)
84
+ def shutdown
111
85
  @running = false
112
- @wait = wait
113
86
  end
114
87
 
115
88
  private
@@ -121,7 +94,7 @@ module Skiplock
121
94
  disposed = true
122
95
  if job_from_db
123
96
  job, ex = YAML.load_file(f) rescue nil
124
- disposed = job.dispose(ex)
97
+ disposed = job.dispose(ex, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries])
125
98
  end
126
99
  File.delete(f) if disposed
127
100
  end
@@ -129,15 +102,14 @@ module Skiplock
129
102
 
130
103
  def do_work
131
104
  while @running
132
- @last_dispatch_at = Time.now.to_f - 1 # 1 second allowance for timedrift
133
- result = Job.dispatch(queues_order_query: @queues_order_query, worker_id: @worker.id)
134
- next if result.is_a?(Job)
105
+ @last_dispatch_at = Time.now.to_f - 1 # 1 second allowance for time drift
106
+ result = Job.dispatch(queues_order_query: @queues_order_query, worker_id: @worker.id, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries])
107
+ next if result.is_a?(Job) && Time.now.to_f >= @next_schedule_at
135
108
  @next_schedule_at = result if result.is_a?(Float)
136
109
  break
137
110
  end
138
111
  rescue Exception => ex
139
- STDERR.puts ex.message
140
- STDERR.puts ex.backtrace
112
+ Skiplock.logger.error(ex)
141
113
  Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
142
114
  @last_exception = ex
143
115
  end
@@ -0,0 +1,25 @@
1
+ module Skiplock
2
+ module Extension
3
+ class Proxy < BasicObject
4
+ def initialize(target, options = {})
5
+ @target = target
6
+ @options = options
7
+ end
8
+
9
+ def method_missing(name, *args)
10
+ ProxyJob.set(@options).perform_later(::YAML.dump([ @target, name, args ]))
11
+ end
12
+ end
13
+
14
+ class ProxyJob < ActiveJob::Base
15
+ def perform(yml)
16
+ target, method_name, args = ::YAML.load(yml)
17
+ target.__send__(method_name, *args)
18
+ end
19
+ end
20
+
21
+ def skiplock(options = {})
22
+ Proxy.new(self, options)
23
+ end
24
+ end
25
+ end
data/lib/skiplock/job.rb CHANGED
@@ -1,24 +1,34 @@
1
1
  module Skiplock
2
2
  class Job < ActiveRecord::Base
3
- def self.dispatch(queues_order_query: nil, worker_id: nil)
4
- self.connection.exec_query('BEGIN')
5
- 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
6
- if job.nil? || job.scheduled_at.to_f > Time.now.to_f
7
- self.connection.exec_query('END')
8
- return (job ? job.scheduled_at.to_f : Float::INFINITY)
3
+ self.implicit_order_column = 'created_at'
4
+
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
9
11
  end
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
- self.connection.exec_query('END')
12
12
  job.data ||= {}
13
13
  job.exception_executions ||= {}
14
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
15
  job.executions = (job.executions || 0) + 1
16
+ Skiplock.logger.info "[Skiplock] Performing #{job.job_class} (#{job.id}) from queue '#{job.queue_name || 'default'}'..."
16
17
  Thread.current[:skiplock_dispatch_job] = job
18
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
17
19
  begin
18
20
  ActiveJob::Base.execute(job_data)
19
21
  rescue Exception => ex
22
+ Skiplock.logger.error(ex)
23
+ end
24
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ job_name = job.job_class
26
+ if job.job_class == 'Skiplock::Extension::ProxyJob'
27
+ target, method_name = ::YAML.load(job.data['arguments'].first)
28
+ job_name = "'#{target.name}.#{method_name}'"
20
29
  end
21
- job.dispose(ex)
30
+ Skiplock.logger.info "[Skiplock] Performed #{job_name} (#{job.id}) from queue '#{job.queue_name || 'default'}' in #{end_time - start_time} seconds"
31
+ job.dispose(ex, purge_completion: purge_completion, max_retries: max_retries)
22
32
  ensure
23
33
  Thread.current[:skiplock_dispatch_job] = nil
24
34
  end
@@ -35,22 +45,27 @@ module Skiplock
35
45
  Thread.current[:skiplock_dispatch_job].scheduled_at = timestamp
36
46
  Thread.current[:skiplock_dispatch_job]
37
47
  else
38
- Job.create!(id: activejob.job_id, job_class: activejob.class.name, queue_name: activejob.queue_name, locale: activejob.locale, timezone: activejob.timezone, priority: activejob.priority, data: { 'arguments' => activejob.serialize['arguments'] }, scheduled_at: timestamp)
48
+ serialize = activejob.serialize
49
+ Job.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'] }, 'scheduled_at' => timestamp))
39
50
  end
40
51
  end
41
52
 
42
- def dispose(ex)
53
+ def self.reset_retry_schedules
54
+ 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)
55
+ end
56
+
57
+ def dispose(ex, purge_completion: true, max_retries: 20)
58
+ dup = self.dup
43
59
  self.running = false
44
60
  self.updated_at = (Time.now > self.updated_at ? Time.now : self.updated_at + 1)
45
61
  if ex
46
62
  self.exception_executions["[#{ex.class.name}]"] = (self.exception_executions["[#{ex.class.name}]"] || 0) + 1 unless self.exception_executions.key?('activejob_retry')
47
- if self.executions >= Settings['max_retries'] || self.exception_executions.key?('activejob_retry')
63
+ if self.executions >= max_retries || self.exception_executions.key?('activejob_retry')
48
64
  self.expired_at = Time.now
49
- self.save!
50
65
  else
51
66
  self.scheduled_at = Time.now + (5 * 2**self.executions)
52
- self.save!
53
67
  end
68
+ self.save!
54
69
  Skiplock.on_errors.each { |p| p.call(ex) }
55
70
  elsif self.exception_executions.try(:key?, 'activejob_retry')
56
71
  self.save!
@@ -67,7 +82,7 @@ module Skiplock
67
82
  else
68
83
  self.delete
69
84
  end
70
- elsif Settings['purge_completion']
85
+ elsif purge_completion
71
86
  self.delete
72
87
  else
73
88
  self.finished_at = Time.now
@@ -76,7 +91,7 @@ module Skiplock
76
91
  end
77
92
  self
78
93
  rescue
79
- File.write("tmp/skiplock/#{self.id}", [self, ex].to_yaml)
94
+ File.write("tmp/skiplock/#{self.id}", [dup, ex].to_yaml)
80
95
  nil
81
96
  end
82
97
  end
@@ -1,49 +1,83 @@
1
1
  module Skiplock
2
2
  class Manager
3
- def self.start(standalone: false, restart: false, workers: nil, max_retries: nil, max_threads: nil, min_threads: nil, logging: nil)
4
- unless Settings.frozen?
5
- load_settings
6
- Settings['logging'] = logging if logging
7
- Settings['max_retries'] = max_retries if max_retries
8
- Settings['max_threads'] = max_threads if max_threads
9
- Settings['min_threads'] = min_threads if min_threads
10
- Settings['workers'] = workers if workers
11
- Settings['max_retries'] = 20 if Settings['max_retries'] > 20
12
- Settings['max_retries'] = 0 if Settings['max_retries'] < 0
13
- Settings['max_threads'] = 1 if Settings['max_threads'] < 1
14
- Settings['max_threads'] = 20 if Settings['max_threads'] > 20
15
- Settings['min_threads'] = 0 if Settings['min_threads'] < 0
16
- Settings['workers'] = 0 if Settings['workers'] < 0
17
- Settings['workers'] = 1 if standalone && Settings['workers'] <= 0
18
- Settings.freeze
19
- end
20
- return unless standalone || restart || (caller.any?{|l| l =~ %r{/rack/}} && Settings['workers'] == 0)
21
- if standalone
22
- self.standalone
3
+ def initialize(**config)
4
+ @config = Skiplock::DEFAULT_CONFIG.dup
5
+ @config.merge!(YAML.load_file('config/skiplock.yml')) rescue nil
6
+ @config.symbolize_keys!
7
+ @config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
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)
11
+ @config[:hostname] = `hostname -f`.strip
12
+ do_config
13
+ banner
14
+ cleanup_workers
15
+ create_worker
16
+ ActiveJob::Base.logger = nil
17
+ if @config[:standalone]
18
+ standalone
23
19
  else
24
- @dispatcher = Dispatcher.new
25
- @thread = @dispatcher.run
26
- at_exit { self.shutdown }
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
26
+ end
27
27
  end
28
- ActiveJob::Base.logger = nil
29
28
  end
30
-
31
- def self.shutdown(wait: true)
32
- if @dispatcher && @thread
33
- @dispatcher.shutdown(wait: wait)
34
- @thread.join
35
- @dispatcher = nil
36
- @thread = nil
29
+
30
+ private
31
+
32
+ def banner
33
+ title = "[Skiplock] V#{Skiplock::VERSION} (Rails #{Rails::VERSION::STRING} | Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
34
+ @logger.info "-"*(title.length)
35
+ @logger.info title
36
+ @logger.info "-"*(title.length)
37
+ @logger.info "ClassMethod Extensions: #{@config[:extensions]}"
38
+ @logger.info " Purge completion: #{@config[:purge_completion]}"
39
+ @logger.info " Notification: #{@config[:notification]}#{(' (' + @notification + ')') if @config[:notification] == 'auto'}"
40
+ @logger.info " Max retries: #{@config[:max_retries]}"
41
+ @logger.info " Min threads: #{@config[:min_threads]}"
42
+ @logger.info " Max threads: #{@config[:max_threads]}"
43
+ @logger.info " Environment: #{Rails.env}"
44
+ @logger.info " Logging: #{@config[:logging]}"
45
+ @logger.info " Workers: #{@config[:workers]}"
46
+ @logger.info " Queues: #{@config[:queues].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if @config[:queues].is_a?(Hash)
47
+ @logger.info " PID: #{Process.pid}"
48
+ @logger.info "-"*(title.length)
49
+ end
50
+
51
+ def cleanup_workers
52
+ delete_ids = []
53
+ Worker.where(hostname: @config[:hostname]).each do |worker|
54
+ sid = Process.getsid(worker.pid) rescue nil
55
+ delete_ids << worker.id if worker.sid != sid || worker.updated_at < 30.minutes.ago
56
+ end
57
+ if delete_ids.count > 0
58
+ Job.where(running: true, worker_id: delete_ids).update_all(running: false, worker_id: nil)
59
+ Worker.where(id: delete_ids).delete_all
37
60
  end
38
61
  end
39
62
 
40
- private
63
+ def create_worker(pid: Process.pid, sid: Process.getsid(), master: true)
64
+ @worker = Worker.create!(pid: pid, sid: sid, master: master, hostname: @config[:hostname], capacity: @config[:max_threads])
65
+ rescue
66
+ @worker = Worker.create!(pid: pid, sid: sid, master: false, hostname: @config[:hostname], capacity: @config[:max_threads])
67
+ end
41
68
 
42
- def self.load_settings
43
- config = YAML.load_file('config/skiplock.yml') rescue {}
44
- Settings.merge!(config)
45
- Settings['queues'].values.each { |v| raise 'Queue value must be an integer' unless v.is_a?(Integer) } if Settings['queues'].is_a?(Hash)
46
- @notification = Settings['notification'] = Settings['notification'].to_s.downcase
69
+ def do_config
70
+ @config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
71
+ @config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
72
+ @config[:max_retries] = 20 if @config[:max_retries] > 20
73
+ @config[:max_retries] = 0 if @config[:max_retries] < 0
74
+ @config[:max_threads] = 1 if @config[:max_threads] < 1
75
+ @config[:max_threads] = 20 if @config[:max_threads] > 20
76
+ @config[:min_threads] = 0 if @config[:min_threads] < 0
77
+ @config[:workers] = 0 if @config[:workers] < 0
78
+ @config[:workers] = 1 if @config[:standalone] && @config[:workers] <= 0
79
+ @config[:queues].values.each { |v| raise 'Queue value must be an integer' unless v.is_a?(Integer) } if @config[:queues].is_a?(Hash)
80
+ @notification = @config[:notification]
47
81
  if @notification == 'auto'
48
82
  if defined?(Airbrake)
49
83
  @notification = 'airbrake'
@@ -52,7 +86,7 @@ module Skiplock
52
86
  elsif defined?(ExceptionNotifier)
53
87
  @notification = 'exception_notification'
54
88
  else
55
- puts "Unable to detect any known exception notification gem. Please define custom 'on_error' callback function and disable 'auto' notification in 'config/skiplock.yml'"
89
+ @logger.info "Unable to detect any known exception notification gem. Please define custom 'on_error' callback function and disable 'auto' notification in 'config/skiplock.yml'"
56
90
  exit
57
91
  end
58
92
  end
@@ -73,92 +107,52 @@ module Skiplock
73
107
  ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace)
74
108
  end
75
109
  end
76
- rescue Exception => e
77
- STDERR.puts "Invalid configuration 'config/skiplock.yml': #{e.message}"
78
- exit
110
+ Skiplock.logger = ActiveSupport::Logger.new(STDOUT)
111
+ Skiplock.logger.level = Rails.logger.level
112
+ @logger = Skiplock.logger
113
+ if @config[:logging]
114
+ Skiplock.logger.extend(ActiveSupport::Logger.broadcast(::Logger.new('log/skiplock.log')))
115
+ if @config[:standalone]
116
+ Rails.logger.reopen('/dev/null')
117
+ Rails.logger.extend(ActiveSupport::Logger.broadcast(Skiplock.logger))
118
+ end
119
+ end
120
+ Skiplock.on_errors.freeze unless Skiplock.on_errors.frozen?
79
121
  end
80
122
 
81
- def self.standalone
82
- if Settings['logging']
83
- log_timestamp = (Settings['logging'].to_s == 'timestamp')
84
- logfile = File.open('log/skiplock.log', 'a')
85
- logfile.sync = true
86
- $stdout = Demux.new(logfile, STDOUT, timestamp: log_timestamp)
87
- errfile = File.open('log/skiplock.error.log', 'a')
88
- errfile.sync = true
89
- $stderr = Demux.new(errfile, STDERR, timestamp: log_timestamp)
90
- logger = ActiveSupport::Logger.new($stdout)
91
- logger.level = Rails.logger.level
92
- Rails.logger.reopen('/dev/null')
93
- Rails.logger.extend(ActiveSupport::Logger.broadcast(logger))
94
- end
95
- title = "Skiplock version: #{Skiplock::VERSION} (Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
96
- puts "-"*(title.length)
97
- puts title
98
- puts "-"*(title.length)
99
- puts "Purge completion: #{Settings['purge_completion']}"
100
- puts " Notification: #{Settings['notification']}#{(' (' + @notification + ')') if Settings['notification'] == 'auto'}"
101
- puts " Max retries: #{Settings['max_retries']}"
102
- puts " Min threads: #{Settings['min_threads']}"
103
- puts " Max threads: #{Settings['max_threads']}"
104
- puts " Environment: #{Rails.env}"
105
- puts " Logging: #{Settings['logging']}"
106
- puts " Workers: #{Settings['workers']}"
107
- puts " Queues: #{Settings['queues'].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if Settings['queues'].is_a?(Hash)
108
- puts " PID: #{Process.pid}"
109
- puts "-"*(title.length)
123
+ def standalone
110
124
  parent_id = Process.pid
111
125
  shutdown = false
112
126
  Signal.trap("INT") { shutdown = true }
113
127
  Signal.trap("TERM") { shutdown = true }
114
- worker_pids = []
115
- (Settings['workers']-1).times do |n|
116
- worker_pids << fork do
117
- dispatcher = Dispatcher.new(master: false, worker_num: n+1)
128
+ (@config[:workers] - 1).times do |n|
129
+ fork do
130
+ sleep 1
131
+ worker = create_worker(master: false)
132
+ dispatcher = Dispatcher.new(worker: worker, worker_num: n + 1, **@config)
118
133
  thread = dispatcher.run
119
134
  loop do
120
135
  sleep 0.5
121
136
  break if shutdown || Process.ppid != parent_id
122
137
  end
123
- dispatcher.shutdown(wait: true)
124
- thread.join
138
+ dispatcher.shutdown
139
+ thread.join(@config[:graceful_shutdown])
140
+ worker.delete
125
141
  exit
126
142
  end
127
143
  end
128
- sleep 0.1
129
- dispatcher = Dispatcher.new(worker_pids: worker_pids)
144
+ dispatcher = Dispatcher.new(worker: @worker, **@config)
130
145
  thread = dispatcher.run
131
146
  loop do
132
147
  sleep 0.5
133
148
  break if shutdown
134
149
  end
150
+ @logger.info "[Skiplock] Terminating signal... Waiting for jobs to finish (up to #{@config[:graceful_shutdown]} seconds)..." if @config[:graceful_shutdown]
135
151
  Process.waitall
136
- dispatcher.shutdown(wait: true)
137
- thread.join
138
- end
139
-
140
- class Demux
141
- def initialize(*targets, timestamp: true)
142
- @targets = targets
143
- @timestamp = timestamp
144
- end
145
-
146
- def close
147
- @targets.each(&:close)
148
- end
149
-
150
- def flush
151
- @targets.each(&:flush)
152
- end
153
-
154
- def tty?
155
- true
156
- end
157
-
158
- def write(*args)
159
- args.prepend("[#{Time.now.utc}]: ") if @timestamp
160
- @targets.each {|t| t.write(*args)}
161
- end
152
+ dispatcher.shutdown
153
+ thread.join(@config[:graceful_shutdown])
154
+ @worker.delete
155
+ @logger.info "[Skiplock] Shutdown completed."
162
156
  end
163
157
  end
164
158
  end
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.9'
2
+ VERSION = Version = '1.0.10'
3
3
  end
4
4
 
@@ -1,4 +1,5 @@
1
1
  module Skiplock
2
2
  class Worker < ActiveRecord::Base
3
+ self.implicit_order_column = 'created_at'
3
4
  end
4
5
  end
data/lib/skiplock.rb CHANGED
@@ -4,29 +4,31 @@ require 'active_record'
4
4
  require 'skiplock/counter'
5
5
  require 'skiplock/cron'
6
6
  require 'skiplock/dispatcher'
7
+ require 'skiplock/extension'
7
8
  require 'skiplock/job'
8
9
  require 'skiplock/manager'
9
10
  require 'skiplock/worker'
10
11
  require 'skiplock/version'
11
12
 
12
13
  module Skiplock
13
- Settings = {
14
- 'logging' => 'timestamp',
15
- 'min_threads' => 1,
16
- 'max_threads' => 5,
17
- 'max_retries' => 20,
18
- 'notification' => 'auto',
19
- 'purge_completion' => true,
20
- 'queues' => {
21
- 'default' => 200,
22
- 'mailers' => 100
23
- },
24
- 'workers' => 0
25
- }
26
- mattr_reader :on_errors, default: []
14
+ DEFAULT_CONFIG = { 'extensions' => false, 'logging' => true, 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 5, 'max_retries' => 20, 'notification' => 'none', 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
15
+
16
+ def self.logger=(l)
17
+ @logger = l
18
+ end
19
+
20
+ def self.logger
21
+ @logger
22
+ end
27
23
 
28
24
  def self.on_error(&block)
29
- @@on_errors << block
25
+ @on_errors ||= []
26
+ @on_errors << block
27
+ block
28
+ end
29
+
30
+ def self.on_errors
31
+ @on_errors || [].freeze
30
32
  end
31
33
 
32
34
  def self.table_name_prefix
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.9
4
+ version: 1.0.10
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-05-08 00:00:00.000000000 Z
11
+ date: 2021-08-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -85,6 +85,7 @@ files:
85
85
  - lib/skiplock/counter.rb
86
86
  - lib/skiplock/cron.rb
87
87
  - lib/skiplock/dispatcher.rb
88
+ - lib/skiplock/extension.rb
88
89
  - lib/skiplock/job.rb
89
90
  - lib/skiplock/manager.rb
90
91
  - lib/skiplock/version.rb