skiplock 1.0.7 → 1.0.11

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: 9b5f33c5c4f2f2223b68f6d025989cc3eb6568fb52a7e39ef3bf4c48a1807ec3
4
- data.tar.gz: 3839f01f8771b1ec06a0ab33c32555d4aa7ad0a48e142d0224fe46868692bfa5
3
+ metadata.gz: 432874dc801864f01a8c4de896f330ec62cecdb43d71ededc622ece7d9a17402
4
+ data.tar.gz: 6034dcb3cfa194b186465a5a84227899c8f00fdf4d62d5b20e0370c9c122cde5
5
5
  SHA512:
6
- metadata.gz: 7bf28c36dca7f41518c6d5be2651ff7b40e0f62c526248a478db4a88545b7663260225de5b2c6eacf89e17e34cf24ac2fd38c0bbf95c4cac54a8f57f68fd772a
7
- data.tar.gz: 3998b4377bbe2dd0b993ac9abcf848c6164fcb4378387cc8c7187d507c6fc6df1676fdba30ce98e26ac41730d0569a64197317c52447b1e8d6a06c93fb313fa7
6
+ metadata.gz: 89f0ef53c0740cf5522e20aa1473ed9020bf5d116054acbb77f451472c1d9af2b123b47cdc593efb4545383829609537a4e2395b4c23c7482f04cff04b11ceab
7
+ data.tar.gz: 640fa077844855e312b6018d2858cb495915fba0f167c711540ff3b713c66dfc5aa76ca283d9dffaf3d778f37c7025a1f3f7ee3728d4120d87bc25079717bc60
data/README.md CHANGED
@@ -48,72 +48,74 @@ 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
+ notification: custom
58
+ extensions: false
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
66
  - **min_threads** (*integer*): sets minimum number of threads staying idle
67
67
  - **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`)
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
+ - **logfile** (*string*): path filename for skiplock logs; empty logfile will disable logging
70
+ - **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
71
+ - **extensions** (*boolean*): enable or disable the class method extension. See `ClassMethod extension` for more details
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
- - **queues** (*hash*): defines the set of queues with priorites; lower priority takes precedence
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 web server like Puma, then it should be configured as below:
76
- ```ruby
77
- # config/puma.rb
78
- before_fork do
79
- # ...
80
- Skiplock::Manager.shutdown
81
- end
82
75
 
83
- on_worker_boot do
84
- # ...
85
- Skiplock::Manager.start
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.
87
78
 
88
- on_worker_shutdown do
89
- # ...
90
- Skiplock::Manager.shutdown
91
- end
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, --logfile STRING Full path to logfile
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
92
92
  ```
93
93
 
94
94
  ## Usage
95
-
96
- - Inside the Rails application, queue your job:
95
+ Inside the Rails application:
96
+ - queue your job
97
97
  ```ruby
98
98
  MyJob.perform_later
99
99
  ```
100
- - Skiplock supports all ActiveJob features:
100
+ - Skiplock supports all ActiveJob features
101
101
  ```ruby
102
102
  MyJob.set(queue: 'my_queue', wait: 5.minutes, priority: 10).perform_later(1,2,3)
103
103
  ```
104
- - Outside of Rails application, queue the jobs by inserting the job records directly to the database table eg:
104
+ Outside the Rails application:
105
+ - queue the jobs by inserting the job records directly to the database table
105
106
  ```sql
106
107
  INSERT INTO skiplock.jobs(job_class) VALUES ('MyJob');
107
108
  ```
108
- - Or with scheduling, priority, queue and arguments:
109
+ - with scheduling, priority, queue and arguments
109
110
  ```sql
110
- INSERT INTO skiplock.jobs(job_class,queue_name,priority,scheduled_at,data) VALUES ('MyJob','my_queue',10,NOW()+INTERVAL '5 min','{"arguments":[1,2,3]}');
111
+ INSERT INTO skiplock.jobs(job_class, queue_name, priority, scheduled_at, data)
112
+ VALUES ('MyJob', 'my_queue', 10, NOW() + INTERVAL '5 min', '{"arguments":[1,2,3]}');
111
113
  ```
112
- ## Queues priority vs Job priority
113
- *Why do queues have priorities when jobs already have priorities?*
114
+ ## Queue priority vs Job priority
115
+ *Why do queues use priorities when jobs already have priorities?*
114
116
  - Jobs are only prioritized with other jobs from the same queue
115
117
  - Queues, on the other hand, are prioritized with other queues
116
- - Rails has built-in queues that dispatches jobs without priorities (eg. Mail Delivery will queue as **mailers** with no priority)
118
+ - Rails has built-in queues that dispatch jobs without priorities (eg. Mail Delivery will queue as **mailers** with no priority)
117
119
 
118
120
  ## Cron system
119
121
  `Skiplock` provides the capability to setup cron jobs for running tasks periodically. It fully supports the cron syntax to specify the frequency of the jobs. To setup a cron job, simply assign a valid cron schedule to the constant `CRON` for the Job Class.
@@ -135,7 +137,7 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
135
137
  - to remove the cron schedule from the job, simply comment out the constant definition or delete the line then re-deploy the application. At startup, the cron jobs that were undefined will be removed automatically
136
138
 
137
139
  ## Retry system
138
- `Skiplock` fully supports ActiveJob built-in retry system. It also has its own retry system for fallback. To use ActiveJob retry system, define the rescue blocks per ActiveJob's documentation.
140
+ `Skiplock` fully supports ActiveJob built-in retry system. It also has its own retry system for fallback. To use ActiveJob retry system, define the `retry_on` block per ActiveJob's documentation.
139
141
  - configures `MyJob` to retry at maximum 20 attempts on StandardError with fixed delay of 5 seconds
140
142
  ```ruby
141
143
  class MyJob < ActiveJob::Base
@@ -151,21 +153,37 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
151
153
  # ...
152
154
  end
153
155
  ```
154
- Once the retry attempt limit configured in ActiveJob has been reached, the control will be passed back to `skiplock` to be marked as an expired job.
156
+ If the retry attempt limit configured in ActiveJob has been reached, then the control will be passed back to `skiplock` to be marked as an expired job.
155
157
 
156
- If the rescue blocks are 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.
158
+ 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.
157
159
 
158
160
  ## Notification system
159
- `Skiplock` can use existing exception notification library to notify errors and exceptions. It supports `airbrake`, `bugsnag`, and `exception_notification`. A customized function can also be called whenever an exception occurs; this can be configured in an initializer as below:
161
+ `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:
160
162
  ```ruby
161
163
  # config/initializers/skiplock.rb
162
- Skiplock.on_error = -> (ex, previous = nil) do
164
+ Skiplock.on_error do |ex, previous|
163
165
  if ex.backtrace != previous.try(:backtrace)
164
166
  # sends custom email on new exceptions only
165
167
  # the same repeated exceptions will only be sent once to avoid SPAM
168
+ # NOTE: exceptions generated from Job executions will not provide 'previous' exceptions
166
169
  end
167
170
  end
171
+ # supports multiple 'on_error' event callbacks
168
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
+ ```
169
187
 
170
188
  ## Contributing
171
189
 
data/bin/skiplock CHANGED
@@ -1,3 +1,28 @@
1
1
  #!/usr/bin/env ruby
2
+ require 'optparse'
3
+ options = {}
4
+ begin
5
+ op = OptionParser.new do |opts|
6
+ opts.banner = "Usage: #{File.basename($0)} [options]"
7
+ opts.on('-e', '--environment STRING', String, 'Rails environment')
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')
13
+ opts.on('-w', '--workers NUM', Integer, 'Number of workers')
14
+ opts.on_tail('-h', '--help', 'Show this message') do
15
+ exit
16
+ end
17
+ end
18
+ op.parse!(into: options)
19
+ rescue Exception => e
20
+ puts "\n#{e.message}\n\n" unless e.is_a?(SystemExit)
21
+ puts op
22
+ exit
23
+ end
24
+ options.transform_keys! { |k| k.to_s.gsub('-', '_').to_sym }
25
+ env = options.delete(:environment)
26
+ ENV['RAILS_ENV'] = env if env
2
27
  require File.expand_path("config/environment.rb")
3
- Skiplock::Manager.start(standalone: true)
28
+ Skiplock::Manager.new(**options.merge(standalone: true))
@@ -2,11 +2,11 @@ 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)
9
- enqueue_at(job, nil)
9
+ Skiplock::Job.enqueue(job)
10
10
  end
11
11
 
12
12
  def enqueue_at(job, timestamp)
@@ -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
@@ -1,6 +1,14 @@
1
1
  class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" %>
2
2
  def up
3
3
  execute 'CREATE SCHEMA skiplock'
4
+ create_table 'skiplock.counters', id: :uuid do |t|
5
+ t.integer :completions, null: false, default: 0
6
+ t.integer :dispatches, null: false, default: 0
7
+ t.integer :expiries, null: false, default: 0
8
+ t.integer :failures, null: false, default: 0
9
+ t.integer :retries, null: false, default: 0
10
+ t.date :day, null: false, index: { unique: true }
11
+ end
4
12
  create_table 'skiplock.jobs', id: :uuid do |t|
5
13
  t.uuid :worker_id, index: true
6
14
  t.string :job_class, null: false
@@ -19,10 +27,11 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
19
27
  t.timestamps null: false, default: -> { 'now()' }
20
28
  end
21
29
  create_table 'skiplock.workers', id: :uuid do |t|
22
- t.integer :pid, null: false, index: true
23
- t.integer :ppid, index: true
30
+ t.integer :pid, null: false
31
+ t.integer :sid, null: false
24
32
  t.integer :capacity, null: false
25
33
  t.string :hostname, null: false, index: true
34
+ t.boolean :master, null: false, default: false, index: true
26
35
  t.jsonb :data
27
36
  t.timestamps null: false, index: true, default: -> { 'now()' }
28
37
  end
@@ -31,12 +40,27 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
31
40
  DECLARE
32
41
  record RECORD;
33
42
  BEGIN
43
+ record = NEW;
34
44
  IF (TG_OP = 'DELETE') THEN
35
45
  record = OLD;
36
- ELSE
37
- record = NEW;
46
+ IF (record.finished_at IS NOT NULL OR record.expired_at IS NOT NULL) THEN
47
+ RETURN NULL;
48
+ END IF;
49
+ INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
50
+ ELSIF (record.running = TRUE) THEN
51
+ IF (record.executions IS NULL) THEN
52
+ INSERT INTO skiplock.counters (day,dispatches) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET dispatches = skiplock.counters.dispatches + 1;
53
+ ELSE
54
+ INSERT INTO skiplock.counters (day,retries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET retries = skiplock.counters.retries + 1;
55
+ END IF;
56
+ ELSIF (record.finished_at IS NOT NULL) THEN
57
+ INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
58
+ ELSIF (record.expired_at IS NOT NULL) THEN
59
+ INSERT INTO skiplock.counters (day,expiries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET expiries = skiplock.counters.expiries + 1;
60
+ ELSIF (record.executions IS NOT NULL AND record.scheduled_at IS NOT NULL) THEN
61
+ INSERT INTO skiplock.counters (day,failures) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET failures = skiplock.counters.failures + 1;
38
62
  END IF;
39
- 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 record.scheduled_at) AS FLOAT)::TEXT));
63
+ PERFORM pg_notify('skiplock::jobs', CONCAT(TG_OP,',',record.id::TEXT,',',record.worker_id::TEXT,',',record.queue_name,',',record.running::TEXT,',',CAST(EXTRACT(EPOCH FROM record.expired_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.finished_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM CASE WHEN record.scheduled_at IS NULL THEN record.updated_at ELSE record.scheduled_at END) AS FLOAT)::TEXT));
40
64
  RETURN NULL;
41
65
  END;
42
66
  $$ LANGUAGE plpgsql
@@ -51,7 +75,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
51
75
  ELSE
52
76
  record = NEW;
53
77
  END IF;
54
- 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));
55
79
  RETURN NULL;
56
80
  END;
57
81
  $$ LANGUAGE plpgsql;
@@ -62,6 +86,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
62
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"
63
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"
64
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'"
65
90
  end
66
91
 
67
92
  def down
@@ -0,0 +1,4 @@
1
+ module Skiplock
2
+ class Counter < ActiveRecord::Base
3
+ end
4
+ end
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
@@ -1,132 +1,115 @@
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)
8
+ @last_dispatch_at = 0
12
9
  @next_schedule_at = Time.now.to_f
13
- @running = true
10
+ Process.setproctitle("skiplock-#{@worker.master ? 'master[0]' : 'worker[' + worker_num.to_s + ']'}") if @config[:standalone]
14
11
  end
15
12
 
16
13
  def run
14
+ @running = true
17
15
  Thread.new do
18
- Rails.application.reloader.wrap do
19
- sleep(0.1) while @running && !Rails.application.initialized?
20
- Process.setproctitle("skiplock-#{@master ? 'master[0]' : 'worker[' + @worker_num.to_s + ']'}") if Settings['workers'] > 0
21
- ActiveRecord::Base.connection_pool.with_connection do |connection|
22
- connection.exec_query('LISTEN "skiplock::jobs"')
23
- hostname = `hostname -f`.strip
24
- @worker = Worker.create!(pid: Process.pid, ppid: (@master ? nil : Process.ppid), capacity: Settings['max_threads'], hostname: hostname)
25
- if @master
26
- if File.exists?('tmp/cache/skiplock')
27
- # get performed jobs that could not sync with database
28
- job_ids = File.read('tmp/cache/skiplock').split("\n")
29
- if Settings['purge_completion']
30
- Job.where(id: job_ids, running: true).delete_all
31
- else
32
- Job.where(id: job_ids, running: true).update_all(running: false, finished_at: File.mtime('tmp/cache/skiplock'))
16
+ ActiveRecord::Base.connection_pool.with_connection do |connection|
17
+ connection.exec_query('LISTEN "skiplock::jobs"')
18
+ if @worker.master
19
+ Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
20
+ check_sync_errors
21
+ Cron.setup
22
+ end
23
+ error = false
24
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ while @running
26
+ begin
27
+ if error
28
+ unless connection.active?
29
+ connection.reconnect!
30
+ sleep(0.5)
31
+ connection.exec_query('LISTEN "skiplock::jobs"')
32
+ @next_schedule_at = Time.now.to_f
33
33
  end
34
- File.delete('tmp/cache/skiplock')
34
+ check_sync_errors
35
+ error = false
35
36
  end
36
- # get current worker ids
37
- worker_ids = Worker.where(hostname: hostname, pid: @worker_pids).ids
38
- # reset orphaned jobs of the dead worker ids for retry
39
- Job.where(running: true).where.not(worker_id: worker_ids).update_all(running: false, worker_id: nil)
40
- # remove workers that were not shutdown properly on the host
41
- Worker.where(hostname: hostname).where.not(pid: @worker_pids).delete_all
42
- # reset retries schedules on startup
43
- 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)
44
- Cron.setup
45
- end
46
- error = false
47
- while @running
48
- begin
49
- if error
50
- unless connection.active?
51
- connection.reconnect!
52
- sleep(0.5)
53
- connection.exec_query('LISTEN "skiplock::jobs"')
54
- @next_schedule_at = Time.now
55
- end
56
- error = false
37
+ job_notifications = []
38
+ connection.raw_connection.wait_for_notify(0.1) do |channel, pid, payload|
39
+ job_notifications << payload if payload
40
+ loop do
41
+ payload = connection.raw_connection.notifies
42
+ break unless @running && payload
43
+ job_notifications << payload[:extra]
57
44
  end
58
- if Job::Errors.keys.count > 0
59
- completed_ids = Job::Errors.keys.map { |k| k if Job::Errors[k] }.compact
60
- if Settings['purge_completion'] && completed_ids.count > 0
61
- Job.where(id: completed_ids, running: true).delete_all
62
- elsif completed_ids.count > 0
63
- Job.where(id: completed_ids, running: true).update_all(running: false, finished_at: Time.now)
45
+ job_notifications.each do |n|
46
+ op, id, worker_id, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
47
+ next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0 || scheduled_at.to_f < @last_dispatch_at
48
+ if scheduled_at.to_f <= Time.now.to_f
49
+ @next_schedule_at = Time.now.to_f
50
+ elsif scheduled_at.to_f < @next_schedule_at
51
+ @next_schedule_at = scheduled_at.to_f
64
52
  end
65
- orphaned_ids = Job::Errors.keys.map { |k| k unless Job::Errors[k] }.compact
66
- Job.where(id: orphaned_ids, running: true).update_all(running: false, worker_id: nil, scheduled_at: (Time.now + 10)) if orphaned_ids.count > 0
67
- Job::Errors.clear
68
- end
69
- if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
70
- @executor.post { do_work }
71
53
  end
72
- notifications = []
73
- connection.raw_connection.wait_for_notify(0.1) do |channel, pid, payload|
74
- notifications << payload if payload
75
- loop do
76
- payload = connection.raw_connection.notifies
77
- break unless @running && payload
78
- notifications << payload[:extra]
79
- end
80
- notifications.each do |n|
81
- op, id, worker_id, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
82
- next if op == 'DELETE' || running == 'true' || expired_at.to_s.length > 0 || finished_at.to_s.length > 0
83
- if scheduled_at.to_f <= Time.now.to_f
84
- @next_schedule_at = Time.now.to_f
85
- elsif scheduled_at.to_f < @next_schedule_at
86
- @next_schedule_at = scheduled_at.to_f
87
- end
88
- end
89
- end
90
- rescue Exception => ex
91
- STDERR.puts ex.message
92
- STDERR.puts ex.backtrace
93
- Skiplock.on_error.call(ex, @last_exception) if Skiplock.on_error.is_a?(Proc)
94
- error = true
95
- t = Time.now
96
- while @running
97
- sleep(0.5)
98
- break if Time.now - t > 5
99
- end
100
- @last_exception = ex
101
54
  end
102
- sleep(0.2)
55
+ if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
56
+ @executor.post { do_work }
57
+ end
58
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
59
+ @worker.touch
60
+ timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ end
62
+ rescue Exception => ex
63
+ # most likely error with database connection
64
+ Skiplock.logger.error(ex)
65
+ Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
66
+ error = true
67
+ t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
+ while @running
69
+ sleep(0.5)
70
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - t > 5
71
+ end
72
+ @last_exception = ex
103
73
  end
104
- connection.exec_query('UNLISTEN *')
74
+ sleep(0.2)
105
75
  end
76
+ connection.exec_query('UNLISTEN *')
77
+ @executor.shutdown
78
+ @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
106
79
  end
107
80
  end
108
81
  end
109
82
 
110
- def shutdown(wait: true)
83
+ def shutdown
111
84
  @running = false
112
- @executor.shutdown
113
- @executor.wait_for_termination if wait
114
- @worker.delete if @worker
115
85
  end
116
86
 
117
87
  private
118
88
 
89
+ def check_sync_errors
90
+ # get performed jobs that could not sync with database
91
+ Dir.glob('tmp/skiplock/*').each do |f|
92
+ job_from_db = Job.find_by(id: File.basename(f), running: true)
93
+ disposed = true
94
+ if job_from_db
95
+ job, ex = YAML.load_file(f) rescue nil
96
+ disposed = job.dispose(ex, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries])
97
+ end
98
+ File.delete(f) if disposed
99
+ end
100
+ end
101
+
119
102
  def do_work
120
103
  while @running
121
- result = Job.dispatch(queues_order_query: @queues_order_query, worker_id: @worker.id)
122
- next if result.is_a?(Job)
104
+ @last_dispatch_at = Time.now.to_f - 1 # 1 second allowance for time drift
105
+ result = Job.dispatch(queues_order_query: @queues_order_query, worker_id: @worker.id, purge_completion: @config[:purge_completion], max_retries: @config[:max_retries])
106
+ next if result.is_a?(Job) && Time.now.to_f >= @next_schedule_at
123
107
  @next_schedule_at = result if result.is_a?(Float)
124
108
  break
125
109
  end
126
110
  rescue Exception => ex
127
- STDERR.puts ex.message
128
- STDERR.puts ex.backtrace
129
- Skiplock.on_error.call(ex, @last_exception) if Skiplock.on_error.is_a?(Proc)
111
+ Skiplock.logger.error(ex)
112
+ Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
130
113
  @last_exception = ex
131
114
  end
132
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,73 +1,44 @@
1
1
  module Skiplock
2
2
  class Job < ActiveRecord::Base
3
- self.table_name = 'skiplock.jobs'
4
- Errors = Concurrent::Map.new
3
+ self.implicit_order_column = 'created_at'
5
4
 
6
- # Return: Skiplock::Job if it was executed; otherwise returns the next Job's schedule time in FLOAT
7
- def self.dispatch(queues_order_query: nil, worker_id: nil)
8
- performed = false
9
- self.connection.exec_query('BEGIN')
10
- 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
11
- if job.nil? || job.scheduled_at.to_f > Time.now.to_f
12
- self.connection.exec_query('END')
13
- return (job ? job.scheduled_at.to_f : Float::INFINITY)
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
14
11
  end
15
- job = Skiplock::Job.find_by_sql("UPDATE #{self.table_name} SET running = TRUE, worker_id = #{self.connection.quote(worker_id)} WHERE id = '#{job.id}' RETURNING *").first
16
- self.connection.exec_query('END')
17
12
  job.data ||= {}
18
13
  job.exception_executions ||= {}
19
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'] || []))
20
15
  job.executions = (job.executions || 0) + 1
16
+ Skiplock.logger.info "[Skiplock] Performing #{job.job_class} (#{job.id}) from queue '#{job.queue_name || 'default'}'..."
21
17
  Thread.current[:skiplock_dispatch_job] = job
18
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
19
  begin
23
20
  ActiveJob::Base.execute(job_data)
24
21
  rescue Exception => ex
22
+ Skiplock.logger.error(ex)
25
23
  end
26
- performed = true
27
- job.running = false
28
- if ex
29
- job.exception_executions["[#{ex.class.name}]"] = (job.exception_executions["[#{ex.class.name}]"] || 0) + 1 unless job.exception_executions.key?('activejob_retry')
30
- if job.executions >= Settings['max_retries'] || job.exception_executions.key?('activejob_retry')
31
- job.expired_at = Time.now
32
- job.save!
33
- else
34
- job.scheduled_at = Time.now + (5 * 2**job.executions)
35
- job.save!
36
- end
37
- Skiplock.on_error.call(ex) if Skiplock.on_error.is_a?(Proc) && (job.executions % 3 == 1)
38
- elsif job.exception_executions.key?('activejob_retry')
39
- job.save!
40
- elsif job.cron
41
- job.data['last_cron_run'] = Time.now.utc.to_s
42
- next_cron_at = Cron.next_schedule_at(job.cron)
43
- if next_cron_at
44
- job.executions = 1
45
- job.exception_executions = nil
46
- job.scheduled_at = Time.at(next_cron_at)
47
- job.save!
48
- else
49
- job.delete
24
+ unless ex
25
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
+ job_name = job.job_class
27
+ if job.job_class == 'Skiplock::Extension::ProxyJob'
28
+ target, method_name = ::YAML.load(job.data['arguments'].first)
29
+ job_name = "'#{target.name}.#{method_name}'"
50
30
  end
51
- elsif Settings['purge_completion']
52
- job.delete
53
- else
54
- job.finished_at = Time.now
55
- job.exception_executions = nil
56
- job.save!
31
+ Skiplock.logger.info "[Skiplock] Performed #{job_name} (#{job.id}) from queue '#{job.queue_name || 'default'}' in #{end_time - start_time} seconds"
57
32
  end
58
- job
59
- rescue Exception => ex
60
- if performed
61
- Errors[job.id] = true
62
- File.write('tmp/cache/skiplock', job.id + "\n", mode: 'a')
63
- else
64
- Errors[job.id] = false
65
- end
66
- raise ex
33
+ job.dispose(ex, purge_completion: purge_completion, max_retries: max_retries)
67
34
  ensure
68
35
  Thread.current[:skiplock_dispatch_job] = nil
69
36
  end
70
37
 
38
+ def self.enqueue(activejob)
39
+ self.enqueue_at(activejob, nil)
40
+ end
41
+
71
42
  def self.enqueue_at(activejob, timestamp)
72
43
  timestamp = Time.at(timestamp) if timestamp
73
44
  if Thread.current[:skiplock_dispatch_job].try(:id) == activejob.job_id
@@ -76,8 +47,57 @@ module Skiplock
76
47
  Thread.current[:skiplock_dispatch_job].scheduled_at = timestamp
77
48
  Thread.current[:skiplock_dispatch_job]
78
49
  else
79
- 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, executions: activejob.executions, data: { 'arguments' => activejob.serialize['arguments'] }, scheduled_at: timestamp)
50
+ serialize = activejob.serialize
51
+ Job.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'] }, 'scheduled_at' => timestamp))
52
+ end
53
+ end
54
+
55
+ def self.reset_retry_schedules
56
+ 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)
57
+ end
58
+
59
+ def dispose(ex, purge_completion: true, max_retries: 20)
60
+ dup = self.dup
61
+ self.running = false
62
+ self.worker_id = nil
63
+ self.updated_at = (Time.now > self.updated_at ? Time.now : self.updated_at + 1)
64
+ if ex
65
+ self.exception_executions["[#{ex.class.name}]"] = (self.exception_executions["[#{ex.class.name}]"] || 0) + 1 unless self.exception_executions.key?('activejob_retry')
66
+ if self.executions >= max_retries || self.exception_executions.key?('activejob_retry')
67
+ self.expired_at = Time.now
68
+ else
69
+ self.scheduled_at = Time.now + (5 * 2**self.executions)
70
+ end
71
+ self.save!
72
+ Skiplock.on_errors.each { |p| p.call(ex) }
73
+ elsif self.exception_executions.try(:key?, 'activejob_retry')
74
+ self.save!
75
+ elsif self.cron
76
+ self.data ||= {}
77
+ self.data['crons'] = (self.data['crons'] || 0) + 1
78
+ self.data['last_cron_at'] = Time.now.utc.to_s
79
+ next_cron_at = Cron.next_schedule_at(self.cron)
80
+ if next_cron_at
81
+ self.executions = nil
82
+ self.exception_executions = nil
83
+ self.scheduled_at = Time.at(next_cron_at)
84
+ self.save!
85
+ else
86
+ Skiplock.logger.error "[Skiplock] ERROR: Invalid CRON '#{self.cron}' for Job #{self.job_class}"
87
+ self.delete
88
+ end
89
+ elsif purge_completion
90
+ self.delete
91
+ else
92
+ self.finished_at = Time.now
93
+ self.exception_executions = nil
94
+ self.save!
80
95
  end
96
+ self
97
+ rescue Exception => e
98
+ Skiplock.logger.error(e)
99
+ File.write("tmp/skiplock/#{self.id}", [dup, ex].to_yaml)
100
+ nil
81
101
  end
82
102
  end
83
103
  end
@@ -1,152 +1,163 @@
1
1
  module Skiplock
2
2
  class Manager
3
- def self.start(standalone: 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.freeze
18
- end
19
- return unless standalone || (caller.any?{|l| l =~ %r{/rack/}} && (Settings['workers'] == 0 || Rails.env.development?))
20
- if standalone
21
- 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 || Rails.env.development?))
11
+ @config[:hostname] = `hostname -f`.strip
12
+ do_config
13
+ banner if @config[:standalone]
14
+ cleanup_workers
15
+ create_worker
16
+ ActiveJob::Base.logger = nil
17
+ if @config[:standalone]
18
+ standalone
22
19
  else
23
- @dispatcher = Dispatcher.new
24
- @thread = @dispatcher.run
25
- 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
26
27
  end
28
+ rescue Exception => ex
29
+ @logger.error(ex)
30
+ end
31
+
32
+ private
33
+
34
+ def banner
35
+ title = "Skiplock #{Skiplock::VERSION} (Rails #{Rails::VERSION::STRING} | Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
36
+ @logger.info "-"*(title.length)
37
+ @logger.info title
38
+ @logger.info "-"*(title.length)
39
+ @logger.info "ClassMethod extensions: #{@config[:extensions]}"
40
+ @logger.info " Purge completion: #{@config[:purge_completion]}"
41
+ @logger.info " Notification: #{@config[:notification]}"
42
+ @logger.info " Max retries: #{@config[:max_retries]}"
43
+ @logger.info " Min threads: #{@config[:min_threads]}"
44
+ @logger.info " Max threads: #{@config[:max_threads]}"
45
+ @logger.info " Environment: #{Rails.env}"
46
+ @logger.info " Logfile: #{@config[:logfile] || '(disabled)'}"
47
+ @logger.info " Workers: #{@config[:workers]}"
48
+ @logger.info " Queues: #{@config[:queues].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if @config[:queues].is_a?(Hash)
49
+ @logger.info " PID: #{Process.pid}"
50
+ @logger.info "-"*(title.length)
51
+ @logger.warn "[Skiplock] Custom notification has no registered 'on_error' callback" if Skiplock.on_errors.count == 0
27
52
  end
28
-
29
- def self.shutdown(wait: true)
30
- if @dispatcher && @thread
31
- @dispatcher.shutdown(wait: wait)
32
- @thread.join
53
+
54
+ def cleanup_workers
55
+ delete_ids = []
56
+ Worker.where(hostname: @config[:hostname]).each do |worker|
57
+ sid = Process.getsid(worker.pid) rescue nil
58
+ delete_ids << worker.id if worker.sid != sid || worker.updated_at < 30.minutes.ago
59
+ end
60
+ if delete_ids.count > 0
61
+ Job.where(running: true, worker_id: delete_ids).update_all(running: false, worker_id: nil)
62
+ Worker.where(id: delete_ids).delete_all
33
63
  end
34
64
  end
35
65
 
36
- private
66
+ def create_worker(pid: Process.pid, sid: Process.getsid(), master: true)
67
+ @worker = Worker.create!(pid: pid, sid: sid, master: master, hostname: @config[:hostname], capacity: @config[:max_threads])
68
+ rescue
69
+ @worker = Worker.create!(pid: pid, sid: sid, master: false, hostname: @config[:hostname], capacity: @config[:max_threads])
70
+ end
37
71
 
38
- def self.load_settings
39
- config = YAML.load_file('config/skiplock.yml') rescue {}
40
- Settings.merge!(config)
41
- Settings['queues'].values.each { |v| raise 'Queue value must be an integer' unless v.is_a?(Integer) } if Settings['queues'].is_a?(Hash)
42
- case Settings['notification'].to_s.downcase
43
- when 'auto'
72
+ def do_config
73
+ @config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
74
+ @config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
75
+ @config[:max_retries] = 20 if @config[:max_retries] > 20
76
+ @config[:max_retries] = 0 if @config[:max_retries] < 0
77
+ @config[:max_threads] = 1 if @config[:max_threads] < 1
78
+ @config[:max_threads] = 20 if @config[:max_threads] > 20
79
+ @config[:min_threads] = 0 if @config[:min_threads] < 0
80
+ @config[:workers] = 0 if @config[:workers] < 0
81
+ @config[:workers] = 1 if @config[:standalone] && @config[:workers] <= 0
82
+ @logger = ActiveSupport::Logger.new(STDOUT)
83
+ @logger.level = Rails.logger.level
84
+ Skiplock.logger = @logger
85
+ raise "Cannot create logfile '#{@config[:logfile]}'" if @config[:logfile] && !File.writable?(File.dirname(@config[:logfile]))
86
+ @config[:logfile] = nil if @config[:logfile].to_s.length == 0
87
+ if @config[:logfile]
88
+ @logger.extend(ActiveSupport::Logger.broadcast(::Logger.new(@config[:logfile])))
89
+ if @config[:standalone]
90
+ Rails.logger.reopen('/dev/null')
91
+ Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
92
+ end
93
+ end
94
+ @config[:queues].values.each { |v| raise 'Queue value must be an integer' unless v.is_a?(Integer) } if @config[:queues].is_a?(Hash)
95
+ if @config[:notification] == 'auto'
44
96
  if defined?(Airbrake)
45
- Skiplock.on_error = -> (ex, previous = nil) { Airbrake.notify_sync(ex) unless ex.backtrace == previous.try(:backtrace) }
97
+ @config[:notification] = 'airbrake'
46
98
  elsif defined?(Bugsnag)
47
- Skiplock.on_error = -> (ex, previous = nil) { Bugsnag.notify(ex) unless ex.backtrace == previous.try(:backtrace) }
99
+ @config[:notification] = 'bugsnag'
48
100
  elsif defined?(ExceptionNotifier)
49
- Skiplock.on_error = -> (ex, previous = nil) { ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace) }
101
+ @config[:notification] = 'exception_notification'
50
102
  else
51
- puts "Unable to detect any known exception notification gem. Please define custom 'on_error' function and disable notification in 'config/skiplock.yml'"
52
- exit
103
+ 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'"
53
104
  end
105
+ end
106
+ case @config[:notification]
54
107
  when 'airbrake'
55
108
  raise 'airbrake gem not found' unless defined?(Airbrake)
56
- Skiplock.on_error = -> (ex, previous = nil) { Airbrake.notify_sync(ex) unless ex.backtrace == previous.try(:backtrace) }
109
+ Skiplock.on_error do |ex, previous|
110
+ Airbrake.notify_sync(ex) unless ex.backtrace == previous.try(:backtrace)
111
+ end
57
112
  when 'bugsnag'
58
113
  raise 'bugsnag gem not found' unless defined?(Bugsnag)
59
- Skiplock.on_error = -> (ex, previous = nil) { Bugsnag.notify(ex) unless ex.backtrace == previous.try(:backtrace) }
114
+ Skiplock.on_error do |ex, previous|
115
+ Bugsnag.notify(ex) unless ex.backtrace == previous.try(:backtrace)
116
+ end
60
117
  when 'exception_notification'
61
118
  raise 'exception_notification gem not found' unless defined?(ExceptionNotifier)
62
- Skiplock.on_error = -> (ex, previous = nil) { ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace) }
119
+ Skiplock.on_error do |ex, previous|
120
+ ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace)
121
+ end
122
+ else
123
+ @config[:notification] = 'custom'
63
124
  end
64
- rescue Exception => e
65
- STDERR.puts "Invalid configuration 'config/skiplock.yml': #{e.message}"
66
- exit
125
+ Skiplock.on_errors.freeze unless Skiplock.on_errors.frozen?
67
126
  end
68
127
 
69
- def self.standalone
70
- if Settings['logging']
71
- log_timestamp = (Settings['logging'].to_s == 'timestamp')
72
- logfile = File.open('log/skiplock.log', 'a')
73
- logfile.sync = true
74
- $stdout = Demux.new(logfile, STDOUT, timestamp: log_timestamp)
75
- errfile = File.open('log/skiplock.error.log', 'a')
76
- errfile.sync = true
77
- $stderr = Demux.new(errfile, STDERR, timestamp: log_timestamp)
78
- logger = ActiveSupport::Logger.new($stdout)
79
- logger.level = Rails.logger.level
80
- Rails.logger.reopen('/dev/null')
81
- Rails.logger.extend(ActiveSupport::Logger.broadcast(logger))
82
- end
83
- title = "Skiplock version: #{Skiplock::VERSION} (Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
84
- puts "-"*(title.length)
85
- puts title
86
- puts "-"*(title.length)
87
- puts "Purge completion: #{Settings['purge_completion']}"
88
- puts " Notification: #{Settings['notification']}"
89
- puts " Max retries: #{Settings['max_retries']}"
90
- puts " Min threads: #{Settings['min_threads']}"
91
- puts " Max threads: #{Settings['max_threads']}"
92
- puts " Environment: #{Rails.env}"
93
- puts " Logging: #{Settings['logging']}"
94
- puts " Workers: #{Settings['workers']}"
95
- puts " Queues: #{Settings['queues'].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if Settings['queues'].is_a?(Hash)
96
- puts " PID: #{Process.pid}"
97
- puts "-"*(title.length)
128
+ def standalone
98
129
  parent_id = Process.pid
99
130
  shutdown = false
100
131
  Signal.trap("INT") { shutdown = true }
101
132
  Signal.trap("TERM") { shutdown = true }
102
- worker_pids = []
103
- (Settings['workers']-1).times do |n|
104
- worker_pids << fork do
105
- dispatcher = Dispatcher.new(master: false, worker_num: n+1)
133
+ (@config[:workers] - 1).times do |n|
134
+ fork do
135
+ sleep 1
136
+ worker = create_worker(master: false)
137
+ dispatcher = Dispatcher.new(worker: worker, worker_num: n + 1, **@config)
106
138
  thread = dispatcher.run
107
139
  loop do
108
140
  sleep 0.5
109
141
  break if shutdown || Process.ppid != parent_id
110
142
  end
111
- dispatcher.shutdown(wait: true)
112
- thread.join
143
+ dispatcher.shutdown
144
+ thread.join(@config[:graceful_shutdown])
145
+ worker.delete
113
146
  exit
114
147
  end
115
148
  end
116
- sleep 0.1
117
- dispatcher = Dispatcher.new(worker_pids: worker_pids)
149
+ dispatcher = Dispatcher.new(worker: @worker, **@config)
118
150
  thread = dispatcher.run
119
151
  loop do
120
152
  sleep 0.5
121
153
  break if shutdown
122
154
  end
155
+ @logger.info "[Skiplock] Terminating signal... Waiting for jobs to finish (up to #{@config[:graceful_shutdown]} seconds)..." if @config[:graceful_shutdown]
123
156
  Process.waitall
124
- dispatcher.shutdown(wait: true)
125
- thread.join
126
- end
127
-
128
- class Demux
129
- def initialize(*targets, timestamp: true)
130
- @targets = targets
131
- @timestamp = timestamp
132
- end
133
-
134
- def close
135
- @targets.each(&:close)
136
- end
137
-
138
- def flush
139
- @targets.each(&:flush)
140
- end
141
-
142
- def tty?
143
- true
144
- end
145
-
146
- def write(*args)
147
- args.prepend("[#{Time.now.utc}]: ") if @timestamp
148
- @targets.each {|t| t.write(*args)}
149
- end
157
+ dispatcher.shutdown
158
+ thread.join(@config[:graceful_shutdown])
159
+ @worker.delete
160
+ @logger.info "[Skiplock] Shutdown completed."
150
161
  end
151
162
  end
152
163
  end
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.7'
2
+ VERSION = Version = '1.0.11'
3
3
  end
4
4
 
@@ -1,5 +1,5 @@
1
1
  module Skiplock
2
2
  class Worker < ActiveRecord::Base
3
- self.table_name = 'skiplock.workers'
3
+ self.implicit_order_column = 'created_at'
4
4
  end
5
5
  end
data/lib/skiplock.rb CHANGED
@@ -1,26 +1,37 @@
1
1
  require 'active_job'
2
2
  require 'active_job/queue_adapters/skiplock_adapter'
3
3
  require 'active_record'
4
+ require 'skiplock/counter'
4
5
  require 'skiplock/cron'
5
6
  require 'skiplock/dispatcher'
6
- require 'skiplock/manager'
7
+ require 'skiplock/extension'
7
8
  require 'skiplock/job'
9
+ require 'skiplock/manager'
8
10
  require 'skiplock/worker'
9
11
  require 'skiplock/version'
10
12
 
11
13
  module Skiplock
12
- Settings = {
13
- 'logging' => 'timestamp',
14
- 'min_threads' => 1,
15
- 'max_threads' => 5,
16
- 'max_retries' => 20,
17
- 'notification' => 'auto',
18
- 'purge_completion' => true,
19
- 'queues' => {
20
- 'default' => 200,
21
- 'mailers' => 100
22
- },
23
- 'workers' => 0
24
- }
25
- mattr_accessor :on_error
14
+ DEFAULT_CONFIG = { 'extensions' => false, 'logfile' => 'log/skiplock.log', 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 5, 'max_retries' => 20, 'notification' => 'custom', 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
15
+
16
+ def self.logger=(l)
17
+ @logger = l
18
+ end
19
+
20
+ def self.logger
21
+ @logger
22
+ end
23
+
24
+ def self.on_error(&block)
25
+ @on_errors ||= []
26
+ @on_errors << block
27
+ block
28
+ end
29
+
30
+ def self.on_errors
31
+ @on_errors || []
32
+ end
33
+
34
+ def self.table_name_prefix
35
+ 'skiplock.'
36
+ end
26
37
  end
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.7
4
+ version: 1.0.11
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-03-23 00:00:00.000000000 Z
11
+ date: 2021-08-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -82,8 +82,10 @@ files:
82
82
  - lib/generators/skiplock/install_generator.rb
83
83
  - lib/generators/skiplock/templates/migration.rb.erb
84
84
  - lib/skiplock.rb
85
+ - lib/skiplock/counter.rb
85
86
  - lib/skiplock/cron.rb
86
87
  - lib/skiplock/dispatcher.rb
88
+ - lib/skiplock/extension.rb
87
89
  - lib/skiplock/job.rb
88
90
  - lib/skiplock/manager.rb
89
91
  - lib/skiplock/version.rb
@@ -107,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
107
109
  - !ruby/object:Gem::Version
108
110
  version: '0'
109
111
  requirements: []
110
- rubygems_version: 3.1.4
112
+ rubygems_version: 3.0.3
111
113
  signing_key:
112
114
  specification_version: 4
113
115
  summary: ActiveJob Queue Adapter for PostgreSQL