skiplock 1.0.9 → 1.0.13

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: 89240e4318ca72cf30a3426948d9aa8479f59f85b82e4dca4ef5ee3b449b9894
4
+ data.tar.gz: b8f6799ccf4ba566379548dc4e9c90b6a1202842139b0ac0a3e0247875be00d9
5
5
  SHA512:
6
- metadata.gz: 407469700b1fab6c4ea702b83df0571fd46322ad32c8c8fe8ffc0269e5e93cd9f774b8bd4658021320732ca4de6d7609a64430cbc26bddc14c9f14cf184f92e3
7
- data.tar.gz: 69a5fe7f5c88f21585a4b581d34f44183cb12f25bc0df6e7982497fb63c088316a251cc55c4e712b3cad799ec9aa758c8045d911fbeda393267ed39bc694e9a5
6
+ metadata.gz: 3d8ec5ec2c3b0612fd3077fac5d6ff0c6e1b6826ed74b25b640ccc966874461b470f633c582af4f408f547586e30e1fdc5c22f158ca6fdf5c91000ec33c609a2
7
+ data.tar.gz: 27b6f4746f255777fdb6b3733d08a0592969a45d6d0f315d76231269255e532b51834fa79e1be89a0c18ac62509fe342266c9a8a5c7c038cc2b9e52b120e2469
data/README.md CHANGED
@@ -48,43 +48,51 @@ 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
54
53
  min_threads: 1
55
54
  max_threads: 5
56
55
  max_retries: 20
57
- notification: auto
56
+ logfile: log/skiplock.log
57
+ loglevel: info
58
+ notification: custom
59
+ extensions: false
58
60
  purge_completion: true
59
61
  queues:
60
62
  default: 200
61
- mailers: 100
63
+ mailers: 999
62
64
  workers: 0
63
65
  ```
64
66
  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
67
  - **min_threads** (*integer*): sets minimum number of threads staying idle
67
68
  - **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`)
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
71
+ - **loglevel** (*string*): sets logging level (`debug, info, warn, error, fatal, unknown`)
72
+ - **notification** (*string*): sets the library to be used for notifying errors and exceptions (`auto, airbrake, bugsnag, exception_notification, custom`); using `auto` will detect library if available. See `Notification system` for more details
73
+ - **extensions** (*boolean*): enable or disable the class method extension. See `ClassMethod extension` for more details
70
74
  - **purge_completion** (*boolean*): when set to **true** will delete jobs after they were completed successfully; if set to **false** then the completed jobs should be purged periodically to maximize performance (eg. clean up old jobs after 3 months)
71
75
  - **queues** (*hash*): defines the set of queues with priorities; lower priority takes precedence
72
76
  - **workers** (*integer*) sets the maximum number of processes when running in standalone mode using the `skiplock` executable; setting this to **0** will enable **async mode**
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
77
 
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
78
+ #### **Async mode**
79
+ 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.
80
+
81
+ #### **Standalone mode**
82
+ `Skiplock` standalone mode can be launched by using the `skiplock` executable; command line options can be provided to override the `Skiplock` configuration file.
83
+ ```
84
+ $ bundle exec skiplock -h
85
+ Usage: skiplock [options]
86
+ -e, --environment STRING Rails environment
87
+ -l, --logfile STRING Full path to logfile
88
+ -s, --graceful-shutdown NUM Number of seconds to wait for graceful shutdown
89
+ -r, --max-retries NUM Number of maxixum retries
90
+ -t, --max-threads NUM Number of maximum threads
91
+ -T, --min-threads NUM Number of minimum threads
92
+ -w, --workers NUM Number of workers
93
+ -h, --help Show this message
87
94
  ```
95
+
88
96
  ## Usage
89
97
  Inside the Rails application:
90
98
  - queue your job
@@ -152,17 +160,32 @@ If the retry attempt limit configured in ActiveJob has been reached, then the co
152
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.
153
161
 
154
162
  ## Notification system
155
- `Skiplock` can use existing exception notification library to notify errors and exceptions. It supports `airbrake`, `bugsnag`, and `exception_notification` as shown in the **Configuration** section above. Custom function can also be called whenever an exception occurs; it can be configured in an initializer like below:
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:
156
164
  ```ruby
157
165
  # config/initializers/skiplock.rb
158
166
  Skiplock.on_error do |ex, previous|
159
167
  if ex.backtrace != previous.try(:backtrace)
160
168
  # sends custom email on new exceptions only
161
169
  # the same repeated exceptions will only be sent once to avoid SPAM
170
+ # NOTE: exceptions generated from Job executions will not provide 'previous' exceptions
162
171
  end
163
172
  end
164
- # supports multiple on_error callbacks
173
+ # supports multiple 'on_error' event callbacks
165
174
  ```
175
+ ## ClassMethod extension
176
+ `Skiplock` can add extension to allow all class methods to be performed as a background job; it is disabled in the default configuration. To enable, edit the `config/skiplock.yml` configuration file and change `extensions` to `true`.
177
+ - Queue class method `generate_thumbnails` of class `Image` as background job to run as soon as possible
178
+ ```ruby
179
+ Image.skiplock.generate_thumbnails(height: 100, ratio: true)
180
+ ```
181
+ - Queue class method `cleanup` of class `Session` as background job on queue `maintenance` to run after 5 minutes
182
+ ```ruby
183
+ Session.skiplock(wait: 5.minutes, queue: 'maintenance').cleanup
184
+ ```
185
+ - Queue class method `charge` of class `Subscription` as background job to run tomorrow at noon
186
+ ```ruby
187
+ Subscription.skiplock(wait_until: Date.tomorrow.noon).charge(amount: 100)
188
+ ```
166
189
 
167
190
  ## Contributing
168
191
 
data/bin/skiplock CHANGED
@@ -5,10 +5,11 @@ begin
5
5
  op = OptionParser.new do |opts|
6
6
  opts.banner = "Usage: #{File.basename($0)} [options]"
7
7
  opts.on('-e', '--environment STRING', String, 'Rails environment')
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')
8
+ opts.on('-l', '--logfile STRING', String, 'Full path to logfile')
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
+ Rails.application.config.skiplock.standalone(**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 { Rails.application.config.skiplock = 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
@@ -59,7 +60,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
59
60
  ELSIF (record.executions IS NOT NULL AND record.scheduled_at IS NOT NULL) THEN
60
61
  INSERT INTO skiplock.counters (day,failures) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET failures = skiplock.counters.failures + 1;
61
62
  END IF;
62
- 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));
63
+ PERFORM pg_notify('skiplock::jobs', CONCAT(TG_OP,',',record.id::TEXT,',',record.worker_id::TEXT,',',record.job_class,',',record.queue_name,',',record.running::TEXT,',',CAST(EXTRACT(EPOCH FROM record.expired_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.finished_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM CASE WHEN record.scheduled_at IS NULL THEN record.updated_at ELSE record.scheduled_at END) AS FLOAT)::TEXT));
63
64
  RETURN NULL;
64
65
  END;
65
66
  $$ LANGUAGE plpgsql
@@ -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
@@ -2,17 +2,18 @@ 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?
5
6
  cronjobs = []
6
7
  ActiveJob::Base.descendants.each do |j|
7
8
  next unless j.const_defined?('CRON')
8
9
  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)
10
+ job = Job.find_by('job_class = ? AND cron IS NOT NULL', j.name) || Job.new(job_class: j.name, cron: cron, locale: I18n.locale, timezone: Time.zone.name)
10
11
  time = self.next_schedule_at(cron)
11
12
  if time
12
13
  job.cron = cron
13
14
  job.running = false
14
15
  job.scheduled_at = Time.at(time)
15
- job.save!
16
+ job.save
16
17
  cronjobs << j.name
17
18
  end
18
19
  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,27 +1,6 @@
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)
9
- 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
- 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
- Thread.current[:skiplock_dispatch_job] = job
17
- begin
18
- ActiveJob::Base.execute(job_data)
19
- rescue Exception => ex
20
- end
21
- job.dispose(ex)
22
- ensure
23
- Thread.current[:skiplock_dispatch_job] = nil
24
- end
3
+ self.implicit_order_column = 'created_at'
25
4
 
26
5
  def self.enqueue(activejob)
27
6
  self.enqueue_at(activejob, nil)
@@ -35,22 +14,28 @@ module Skiplock
35
14
  Thread.current[:skiplock_dispatch_job].scheduled_at = timestamp
36
15
  Thread.current[:skiplock_dispatch_job]
37
16
  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)
17
+ serialize = activejob.serialize
18
+ self.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'] }, 'scheduled_at' => timestamp))
39
19
  end
40
20
  end
41
21
 
42
- def dispose(ex)
22
+ def self.reset_retry_schedules
23
+ 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)
24
+ end
25
+
26
+ def dispose(ex, purge_completion: true, max_retries: 20)
27
+ yaml = [self, ex].to_yaml
43
28
  self.running = false
29
+ self.worker_id = nil
44
30
  self.updated_at = (Time.now > self.updated_at ? Time.now : self.updated_at + 1)
45
31
  if ex
46
32
  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')
33
+ if self.executions >= max_retries || self.exception_executions.key?('activejob_retry')
48
34
  self.expired_at = Time.now
49
- self.save!
50
35
  else
51
36
  self.scheduled_at = Time.now + (5 * 2**self.executions)
52
- self.save!
53
37
  end
38
+ self.save!
54
39
  Skiplock.on_errors.each { |p| p.call(ex) }
55
40
  elsif self.exception_executions.try(:key?, 'activejob_retry')
56
41
  self.save!
@@ -65,9 +50,10 @@ module Skiplock
65
50
  self.scheduled_at = Time.at(next_cron_at)
66
51
  self.save!
67
52
  else
53
+ Skiplock.logger.error "[Skiplock] ERROR: Invalid CRON '#{self.cron}' for Job #{self.job_class}"
68
54
  self.delete
69
55
  end
70
- elsif Settings['purge_completion']
56
+ elsif purge_completion
71
57
  self.delete
72
58
  else
73
59
  self.finished_at = Time.now
@@ -75,9 +61,42 @@ module Skiplock
75
61
  self.save!
76
62
  end
77
63
  self
78
- rescue
79
- File.write("tmp/skiplock/#{self.id}", [self, ex].to_yaml)
64
+ rescue Exception => e
65
+ Skiplock.logger.error(e.name)
66
+ Skiplock.logger.error(e.backtrace.join("\n"))
67
+ File.write("tmp/skiplock/#{self.id}", yaml)
80
68
  nil
81
69
  end
70
+
71
+ def execute(purge_completion: true, max_retries: 20)
72
+ Skiplock.logger.info "[Skiplock] Performing #{self.job_class} (#{self.id}) from queue '#{self.queue_name || 'default'}'..."
73
+ self.data ||= {}
74
+ self.exception_executions ||= {}
75
+ 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'] || []))
76
+ self.executions = (self.executions || 0) + 1
77
+ Thread.current[:skiplock_dispatch_job] = self
78
+ activejob = ActiveJob::Base.deserialize(job_data)
79
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
80
+ begin
81
+ activejob.perform_now
82
+ rescue Exception => ex
83
+ end
84
+ if ex || self.exception_executions.key?('activejob_retry')
85
+ Skiplock.logger.error("[Skiplock] Job #{self.job_class} (#{self.id}) was interrupted by an exception#{ ' (rescued and retried by ActiveJob)' if self.exception_executions.key?('activejob_retry') }")
86
+ if ex
87
+ Skiplock.logger.error(ex)
88
+ Skiplock.logger.error(ex.backtrace.join("\n"))
89
+ end
90
+ else
91
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
92
+ job_name = self.job_class
93
+ if self.job_class == 'Skiplock::Extension::ProxyJob'
94
+ target, method_name = ::YAML.load(self.data['arguments'].first)
95
+ job_name = "'#{target.name}.#{method_name}'"
96
+ end
97
+ Skiplock.logger.info "[Skiplock] Performed #{job_name} (#{self.id}) from queue '#{self.queue_name || 'default'}' in #{end_time - start_time} seconds"
98
+ end
99
+ self.dispose(ex, purge_completion: purge_completion, max_retries: max_retries)
100
+ end
82
101
  end
83
102
  end
@@ -1,62 +1,140 @@
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
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
+ @config[:hostname] = `hostname -f`.strip
10
+ configure
11
+ Module.__send__(:include, Skiplock::Extension) if @config[:extensions] == true
12
+ if (caller.any?{ |l| l =~ %r{/rack/} } && (@config[:workers] == 0 || Rails.env.development?))
13
+ cleanup_workers
14
+ @worker = create_worker
15
+ @thread = @worker.run(**@config)
16
+ at_exit do
17
+ @worker.shutdown
18
+ @thread.join(@config[:graceful_shutdown])
19
+ @worker.delete
20
+ end
19
21
  end
20
- return unless standalone || restart || (caller.any?{|l| l =~ %r{/rack/}} && Settings['workers'] == 0)
21
- if standalone
22
- self.standalone
23
- else
24
- @dispatcher = Dispatcher.new
25
- @thread = @dispatcher.run
26
- at_exit { self.shutdown }
22
+ rescue Exception => ex
23
+ @logger.error(ex.name)
24
+ @logger.error(ex.backtrace.join("\n"))
25
+ end
26
+
27
+ def standalone(**options)
28
+ @config.merge!(options)
29
+ Rails.logger.reopen('/dev/null')
30
+ Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
31
+ @config[:workers] = 1 if @config[:workers] <= 0
32
+ banner
33
+ cleanup_workers
34
+ @worker = create_worker
35
+ @parent_id = Process.pid
36
+ @shutdown = false
37
+ Signal.trap("INT") { @shutdown = true }
38
+ Signal.trap("TERM") { @shutdown = true }
39
+ (@config[:workers] - 1).times do |n|
40
+ fork do
41
+ sleep 1
42
+ worker = create_worker(master: false)
43
+ thread = worker.run(worker_num: n + 1, **@config)
44
+ loop do
45
+ sleep 0.5
46
+ break if @shutdown || Process.ppid != @parent_id
47
+ end
48
+ worker.shutdown
49
+ thread.join(@config[:graceful_shutdown])
50
+ worker.delete
51
+ exit
52
+ end
53
+ end
54
+ @thread = @worker.run(**@config)
55
+ loop do
56
+ sleep 0.5
57
+ break if @shutdown
27
58
  end
28
- ActiveJob::Base.logger = nil
59
+ @logger.info "[Skiplock] Terminating signal... Waiting for jobs to finish (up to #{@config[:graceful_shutdown]} seconds)..." if @config[:graceful_shutdown]
60
+ Process.waitall
61
+ @worker.shutdown
62
+ @thread.join(@config[:graceful_shutdown])
63
+ @worker.delete
64
+ @logger.info "[Skiplock] Shutdown completed."
65
+ end
66
+
67
+ private
68
+
69
+ def banner
70
+ title = "Skiplock #{Skiplock::VERSION} (Rails #{Rails::VERSION::STRING} | Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
71
+ @logger.info "-"*(title.length)
72
+ @logger.info title
73
+ @logger.info "-"*(title.length)
74
+ @logger.info "ClassMethod extensions: #{@config[:extensions]}"
75
+ @logger.info " Purge completion: #{@config[:purge_completion]}"
76
+ @logger.info " Notification: #{@config[:notification]}"
77
+ @logger.info " Max retries: #{@config[:max_retries]}"
78
+ @logger.info " Min threads: #{@config[:min_threads]}"
79
+ @logger.info " Max threads: #{@config[:max_threads]}"
80
+ @logger.info " Environment: #{Rails.env}"
81
+ @logger.info " Loglevel: #{@config[:loglevel]}"
82
+ @logger.info " Logfile: #{@config[:logfile] || '(disabled)'}"
83
+ @logger.info " Workers: #{@config[:workers]}"
84
+ @logger.info " Queues: #{@config[:queues].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if @config[:queues].is_a?(Hash)
85
+ @logger.info " PID: #{Process.pid}"
86
+ @logger.info "-"*(title.length)
87
+ @logger.warn "[Skiplock] Custom notification has no registered 'on_error' callback" if Skiplock.on_errors.count == 0
29
88
  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
89
+
90
+ def cleanup_workers
91
+ delete_ids = []
92
+ Worker.where(hostname: @config[:hostname]).each do |worker|
93
+ sid = Process.getsid(worker.pid) rescue nil
94
+ delete_ids << worker.id if worker.sid != sid || worker.updated_at < 30.minutes.ago
37
95
  end
96
+ Worker.where(id: delete_ids).delete_all if delete_ids.count > 0
97
+ Job.where(running: true).where.not(worker_id: Worker.ids).update_all(running: false, worker_id: nil)
38
98
  end
39
99
 
40
- private
100
+ def create_worker(master: true)
101
+ Worker.create!(pid: Process.pid, sid: Process.getsid(), master: master, hostname: @config[:hostname], capacity: @config[:max_threads])
102
+ rescue
103
+ Worker.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: @config[:hostname], capacity: @config[:max_threads])
104
+ end
41
105
 
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
47
- if @notification == 'auto'
106
+ def configure
107
+ @config[:loglevel] = 'info' unless ['debug','info','warn','error','fatal','unknown'].include?(@config[:loglevel].to_s)
108
+ @config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
109
+ @config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
110
+ @config[:max_retries] = 20 if @config[:max_retries] > 20
111
+ @config[:max_retries] = 0 if @config[:max_retries] < 0
112
+ @config[:max_threads] = 1 if @config[:max_threads] < 1
113
+ @config[:max_threads] = 20 if @config[:max_threads] > 20
114
+ @config[:min_threads] = 0 if @config[:min_threads] < 0
115
+ @config[:workers] = 0 if @config[:workers] < 0
116
+ @logger = ActiveSupport::Logger.new(STDOUT)
117
+ @logger.level = @config[:loglevel].to_sym
118
+ Skiplock.logger = @logger
119
+ raise "Cannot create logfile '#{@config[:logfile]}'" if @config[:logfile] && !File.writable?(File.dirname(@config[:logfile]))
120
+ @config[:logfile] = nil if @config[:logfile].to_s.length == 0
121
+ if @config[:logfile]
122
+ @logger.extend(ActiveSupport::Logger.broadcast(::Logger.new(@config[:logfile])))
123
+ ActiveJob::Base.logger = nil
124
+ end
125
+ @config[:queues].values.each { |v| raise 'Queue value must be an integer' unless v.is_a?(Integer) } if @config[:queues].is_a?(Hash)
126
+ if @config[:notification] == 'auto'
48
127
  if defined?(Airbrake)
49
- @notification = 'airbrake'
128
+ @config[:notification] = 'airbrake'
50
129
  elsif defined?(Bugsnag)
51
- @notification = 'bugsnag'
130
+ @config[:notification] = 'bugsnag'
52
131
  elsif defined?(ExceptionNotifier)
53
- @notification = 'exception_notification'
132
+ @config[:notification] = 'exception_notification'
54
133
  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'"
56
- exit
134
+ raise "Unable to detect any known exception notification library. Please define custom 'on_error' event callbacks and change to 'custom' notification in 'config/skiplock.yml'"
57
135
  end
58
136
  end
59
- case @notification
137
+ case @config[:notification]
60
138
  when 'airbrake'
61
139
  raise 'airbrake gem not found' unless defined?(Airbrake)
62
140
  Skiplock.on_error do |ex, previous|
@@ -72,93 +150,10 @@ module Skiplock
72
150
  Skiplock.on_error do |ex, previous|
73
151
  ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace)
74
152
  end
153
+ else
154
+ @config[:notification] = 'custom'
75
155
  end
76
- rescue Exception => e
77
- STDERR.puts "Invalid configuration 'config/skiplock.yml': #{e.message}"
78
- exit
79
- end
80
-
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)
110
- parent_id = Process.pid
111
- shutdown = false
112
- Signal.trap("INT") { shutdown = true }
113
- 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)
118
- thread = dispatcher.run
119
- loop do
120
- sleep 0.5
121
- break if shutdown || Process.ppid != parent_id
122
- end
123
- dispatcher.shutdown(wait: true)
124
- thread.join
125
- exit
126
- end
127
- end
128
- sleep 0.1
129
- dispatcher = Dispatcher.new(worker_pids: worker_pids)
130
- thread = dispatcher.run
131
- loop do
132
- sleep 0.5
133
- break if shutdown
134
- end
135
- 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
156
+ Skiplock.on_errors.freeze unless Skiplock.on_errors.frozen?
162
157
  end
163
158
  end
164
159
  end
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.9'
2
+ VERSION = Version = '1.0.13'
3
3
  end
4
4
 
@@ -1,4 +1,117 @@
1
1
  module Skiplock
2
2
  class Worker < ActiveRecord::Base
3
+ self.implicit_order_column = 'created_at'
4
+
5
+ def run(worker_num: 0, **config)
6
+ @config = config
7
+ @worker_num = worker_num
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], max_threads: @config[:max_threads], max_queue: @config[:max_threads], idletime: 60, auto_terminate: true, fallback_policy: :discard)
11
+ @running = true
12
+ Process.setproctitle("skiplock-#{self.master ? 'master[0]' : 'worker[' + @worker_num.to_s + ']'}") if @config[:standalone]
13
+ Thread.new do
14
+ @connection = self.class.connection
15
+ @connection.exec_query('LISTEN "skiplock::jobs"')
16
+ if self.master
17
+ Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
18
+ check_sync_errors
19
+ Cron.setup
20
+ end
21
+ error = false
22
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
23
+ while @running
24
+ Rails.application.reloader.wrap do
25
+ begin
26
+ if error
27
+ unless @connection.active?
28
+ @connection.reconnect!
29
+ sleep(0.5)
30
+ @connection.exec_query('LISTEN "skiplock::jobs"')
31
+ @next_schedule_at = Time.now.to_f
32
+ end
33
+ check_sync_errors if self.master
34
+ error = false
35
+ end
36
+ job_notifications = []
37
+ @connection.raw_connection.wait_for_notify(0.1) do |channel, pid, payload|
38
+ job_notifications << payload if payload
39
+ loop do
40
+ payload = @connection.raw_connection.notifies
41
+ break unless @running && payload
42
+ job_notifications << payload[:extra]
43
+ end
44
+ job_notifications.each do |n|
45
+ op, id, worker_id, job_class, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
46
+ next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0
47
+ if scheduled_at.to_f <= Time.now.to_f
48
+ @next_schedule_at = Time.now.to_f
49
+ elsif scheduled_at.to_f < @next_schedule_at
50
+ @next_schedule_at = scheduled_at.to_f
51
+ end
52
+ end
53
+ end
54
+ if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
55
+ job = dispatch_job
56
+ if job.is_a?(Job)
57
+ @executor.post(job, @config[:purge_completion], @config[:max_retries]) do |job, purge_completion, max_retries|
58
+ job.execute(purge_completion: purge_completion, max_retries: max_retries)
59
+ end
60
+ else
61
+ @next_schedule_at = job
62
+ end
63
+ end
64
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
65
+ self.touch
66
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ end
68
+ rescue Exception => ex
69
+ # most likely error with database connection
70
+ Skiplock.logger.error(ex.name)
71
+ Skiplock.logger.error(ex.backtrace.join("\n"))
72
+ Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
73
+ error = true
74
+ t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
75
+ while @running
76
+ sleep(0.5)
77
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - t > 5
78
+ end
79
+ @last_exception = ex
80
+ end
81
+ sleep(0.2)
82
+ end
83
+ end
84
+ @connection.exec_query('UNLISTEN *')
85
+ @executor.shutdown
86
+ @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
87
+ end
88
+ end
89
+
90
+ def shutdown
91
+ @running = false
92
+ end
93
+
94
+ private
95
+
96
+ def check_sync_errors
97
+ # get performed jobs that could not sync with database
98
+ Dir.glob('tmp/skiplock/*').each do |f|
99
+ job_from_db = Job.find_by(id: File.basename(f), running: true)
100
+ disposed = true
101
+ if job_from_db
102
+ job, ex = YAML.load_file(f) rescue nil
103
+ disposed = job.dispose(ex, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries])
104
+ end
105
+ File.delete(f) if disposed
106
+ end
107
+ end
108
+
109
+ def dispatch_job
110
+ @connection.transaction do
111
+ 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
112
+ return (job ? job.scheduled_at.to_f : Float::INFINITY) if job.nil? || job.scheduled_at.to_f > Time.now.to_f
113
+ Job.find_by_sql("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
114
+ end
115
+ end
3
116
  end
4
117
  end
data/lib/skiplock.rb CHANGED
@@ -3,30 +3,31 @@ 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'
6
+ require 'skiplock/extension'
7
7
  require 'skiplock/job'
8
8
  require 'skiplock/manager'
9
9
  require 'skiplock/worker'
10
10
  require 'skiplock/version'
11
11
 
12
12
  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: []
13
+ DEFAULT_CONFIG = { 'extensions' => false, 'logfile' => 'log/skiplock.log', 'loglevel' => 'info', 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 5, 'max_retries' => 20, 'notification' => 'custom', 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
14
+
15
+ def self.logger=(l)
16
+ @logger = l
17
+ end
18
+
19
+ def self.logger
20
+ @logger
21
+ end
27
22
 
28
23
  def self.on_error(&block)
29
- @@on_errors << block
24
+ @on_errors ||= []
25
+ @on_errors << block
26
+ block
27
+ end
28
+
29
+ def self.on_errors
30
+ @on_errors || []
30
31
  end
31
32
 
32
33
  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.13
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-09-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -84,7 +84,7 @@ files:
84
84
  - lib/skiplock.rb
85
85
  - lib/skiplock/counter.rb
86
86
  - lib/skiplock/cron.rb
87
- - lib/skiplock/dispatcher.rb
87
+ - lib/skiplock/extension.rb
88
88
  - lib/skiplock/job.rb
89
89
  - lib/skiplock/manager.rb
90
90
  - lib/skiplock/version.rb
@@ -1,145 +0,0 @@
1
- module Skiplock
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
12
- @last_dispatch_at = 0
13
- @next_schedule_at = Time.now.to_f
14
- @running = true
15
- end
16
-
17
- def run
18
- 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
38
- 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
56
- 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
81
- end
82
- 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
- end
99
- sleep(0.2)
100
- end
101
- connection.exec_query('UNLISTEN *')
102
- @executor.shutdown
103
- @executor.wait_for_termination if @wait
104
- @worker.delete if @worker
105
- end
106
- end
107
- end
108
- end
109
-
110
- def shutdown(wait: true)
111
- @running = false
112
- @wait = wait
113
- end
114
-
115
- private
116
-
117
- def check_sync_errors
118
- # get performed jobs that could not sync with database
119
- Dir.glob('tmp/skiplock/*').each do |f|
120
- job_from_db = Job.find_by(id: File.basename(f), running: true)
121
- disposed = true
122
- if job_from_db
123
- job, ex = YAML.load_file(f) rescue nil
124
- disposed = job.dispose(ex)
125
- end
126
- File.delete(f) if disposed
127
- end
128
- end
129
-
130
- def do_work
131
- 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)
135
- @next_schedule_at = result if result.is_a?(Float)
136
- break
137
- end
138
- rescue Exception => ex
139
- STDERR.puts ex.message
140
- STDERR.puts ex.backtrace
141
- Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
142
- @last_exception = ex
143
- end
144
- end
145
- end