skiplock 1.0.7 → 1.0.11

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