skiplock 1.0.9 → 1.0.13

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: 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