skiplock 1.0.9 → 1.0.10

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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