skiplock 1.0.4 → 1.0.9

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: c53cdb42608339ffd7ba7532393e933ce9128757ee35679a665fd2f1e1d0fba3
4
- data.tar.gz: e7dfcc14eed82fef12747d9c0172de006cdb8823fdcaa1d7e944b8d0bde71be1
3
+ metadata.gz: 7a975a3bb22318c6203edd59428a93d6a4abfa5ea1382f35abd774760467dc23
4
+ data.tar.gz: 79b787d23af0db0188aebf1b0361649d906d5f0cca4488d7cfcb3f61927d4781
5
5
  SHA512:
6
- metadata.gz: b32ac8c7b3cf90be24c86310d4e2b2fe250a5ec7b244aad267144f49e09f14d9a46e64214f45afde8093d4a867f1c93245c5e216ac214cacca555f3965770f7c
7
- data.tar.gz: 0a61bf8d14f3162fe981420d43278642ac98a4ab5ac6705bc9a9f65ddd3330e26b6ec1d3ce9a196155e2316ed972954cd657f4d36a48bda83554ea97eb205237
6
+ metadata.gz: 407469700b1fab6c4ea702b83df0571fd46322ad32c8c8fe8ffc0269e5e93cd9f774b8bd4658021320732ca4de6d7609a64430cbc26bddc14c9f14cf184f92e3
7
+ data.tar.gz: 69a5fe7f5c88f21585a4b581d34f44183cb12f25bc0df6e7982497fb63c088316a251cc55c4e712b3cad799ec9aa758c8045d911fbeda393267ed39bc694e9a5
data/README.md CHANGED
@@ -54,7 +54,11 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
54
54
  min_threads: 1
55
55
  max_threads: 5
56
56
  max_retries: 20
57
+ notification: auto
57
58
  purge_completion: true
59
+ queues:
60
+ default: 200
61
+ mailers: 100
58
62
  workers: 0
59
63
  ```
60
64
  Available configuration options are:
@@ -62,47 +66,51 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
62
66
  - **min_threads** (*integer*): sets minimum number of threads staying idle
63
67
  - **max_threads** (*integer*): sets the maximum number of threads allowed to run jobs
64
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`)
65
70
  - **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)
66
- - **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**
71
+ - **queues** (*hash*): defines the set of queues with priorities; lower priority takes precedence
72
+ - **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**
67
73
 
68
74
  #### Async mode
69
- 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:
75
+ When **workers** is set to **0** then the jobs will be performed in the web server process using separate threads. If using multi-worker cluster mode web server like Puma, then it should be configured as below:
70
76
  ```ruby
71
77
  # config/puma.rb
72
- before_fork do
73
- # ...
74
- Skiplock::Manager.shutdown
75
- end
76
-
77
- on_worker_boot do
78
- # ...
79
- Skiplock::Manager.start
78
+ # ...
79
+ on_worker_fork do |worker_index|
80
+ Skiplock::Manager.shutdown if worker_index == 1
80
81
  end
81
82
 
82
- on_worker_shutdown do
83
- # ...
84
- Skiplock::Manager.shutdown
83
+ after_worker_fork do |worker_index|
84
+ # restarts skiplock after all Puma workers have been started
85
+ Skiplock::Manager.start(restart: true) if defined?(Skiplock) && worker_index + 1 == @options[:workers]
85
86
  end
86
87
  ```
87
-
88
88
  ## Usage
89
-
90
- - Inside the Rails application, queue your job:
89
+ Inside the Rails application:
90
+ - queue your job
91
91
  ```ruby
92
92
  MyJob.perform_later
93
93
  ```
94
- - Skiplock supports all ActiveJob features:
94
+ - Skiplock supports all ActiveJob features
95
95
  ```ruby
96
- MyJob.set(wait: 5.minutes, priority: 10).perform_later(1,2,3)
96
+ MyJob.set(queue: 'my_queue', wait: 5.minutes, priority: 10).perform_later(1,2,3)
97
97
  ```
98
- - Outside of Rails application, queue the jobs by inserting the job records directly to the database table eg:
98
+ Outside the Rails application:
99
+ - queue the jobs by inserting the job records directly to the database table
99
100
  ```sql
100
101
  INSERT INTO skiplock.jobs(job_class) VALUES ('MyJob');
101
102
  ```
102
- - Or with scheduling, priority and arguments:
103
+ - with scheduling, priority, queue and arguments
103
104
  ```sql
104
- INSERT INTO skiplock.jobs(job_class,priority,scheduled_at,data) VALUES ('MyJob',10,NOW()+INTERVAL '5 min','{"arguments":[1,2,3]}');
105
+ INSERT INTO skiplock.jobs(job_class, queue_name, priority, scheduled_at, data)
106
+ VALUES ('MyJob', 'my_queue', 10, NOW() + INTERVAL '5 min', '{"arguments":[1,2,3]}');
105
107
  ```
108
+ ## Queue priority vs Job priority
109
+ *Why do queues use priorities when jobs already have priorities?*
110
+ - Jobs are only prioritized with other jobs from the same queue
111
+ - Queues, on the other hand, are prioritized with other queues
112
+ - Rails has built-in queues that dispatch jobs without priorities (eg. Mail Delivery will queue as **mailers** with no priority)
113
+
106
114
  ## Cron system
107
115
  `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.
108
116
  - setup `MyJob` to run as cron job every hour at 30 minutes past
@@ -123,7 +131,7 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
123
131
  - 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
124
132
 
125
133
  ## Retry system
126
- `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.
134
+ `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.
127
135
  - configures `MyJob` to retry at maximum 20 attempts on StandardError with fixed delay of 5 seconds
128
136
  ```ruby
129
137
  class MyJob < ActiveJob::Base
@@ -139,12 +147,22 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
139
147
  # ...
140
148
  end
141
149
  ```
142
- 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.
150
+ 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.
143
151
 
144
- 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.
152
+ 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.
145
153
 
146
154
  ## Notification system
147
- ...
155
+ `Skiplock` can use existing exception notification library to notify errors and exceptions. It supports `airbrake`, `bugsnag`, and `exception_notification` as shown in the **Configuration** section above. Custom function can also be called whenever an exception occurs; it can be configured in an initializer like below:
156
+ ```ruby
157
+ # config/initializers/skiplock.rb
158
+ Skiplock.on_error do |ex, previous|
159
+ if ex.backtrace != previous.try(:backtrace)
160
+ # sends custom email on new exceptions only
161
+ # the same repeated exceptions will only be sent once to avoid SPAM
162
+ end
163
+ end
164
+ # supports multiple on_error callbacks
165
+ ```
148
166
 
149
167
  ## Contributing
150
168
 
data/bin/skiplock CHANGED
@@ -1,3 +1,26 @@
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', '--logging STRING', String, 'Possible values: true, false, timestamp')
9
+ opts.on('-r', '--max_retries NUM', Integer, 'Number of maxixum retries')
10
+ opts.on('-t', '--max_threads NUM', Integer, 'Number of maximum threads')
11
+ opts.on('-T', '--min_threads NUM', Integer, 'Number of minimum threads')
12
+ opts.on('-w', '--workers NUM', Integer, 'Number of workers')
13
+ opts.on_tail('-h', '--help', 'Show this message') do
14
+ exit
15
+ end
16
+ end
17
+ op.parse!(into: options)
18
+ rescue Exception => e
19
+ puts "\n#{e.message}\n\n" unless e.is_a?(SystemExit)
20
+ puts op
21
+ exit
22
+ end
23
+ env = options.delete(:environment)
24
+ ENV['RAILS_ENV'] = env if env
2
25
  require File.expand_path("config/environment.rb")
3
- Skiplock::Manager.start(standalone: true)
26
+ Skiplock::Manager.start(standalone: true, **options)
@@ -6,7 +6,7 @@ module ActiveJob
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)
@@ -1,13 +1,21 @@
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
7
- t.string :cron
8
- t.string :queue_name
15
+ t.string :queue_name, index: true
9
16
  t.string :locale
10
17
  t.string :timezone
18
+ t.string :cron
11
19
  t.integer :priority
12
20
  t.integer :executions
13
21
  t.jsonb :exception_executions
@@ -18,26 +26,63 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
18
26
  t.timestamp :scheduled_at
19
27
  t.timestamps null: false, default: -> { 'now()' }
20
28
  end
21
- create_table 'skiplob.workers', id: :uuid do |t|
29
+ create_table 'skiplock.workers', id: :uuid do |t|
22
30
  t.integer :pid, null: false, index: true
23
31
  t.integer :ppid, index: true
24
- t.integer :remaining_capacity, null: false
32
+ t.integer :capacity, null: false
25
33
  t.string :hostname, null: false, index: true
26
34
  t.jsonb :data
27
35
  t.timestamps null: false, index: true, default: -> { 'now()' }
28
36
  end
29
- execute %(CREATE OR REPLACE FUNCTION skiplock.notify() RETURNS TRIGGER AS $$
30
- BEGIN
31
- IF (NEW.finished_at IS NULL AND NEW.expired_at IS NULL) THEN
32
- PERFORM pg_notify('skiplock', CONCAT(TG_OP,',',NEW.id::TEXT,',',NEW.queue_name,',',NEW.priority,',',CAST(EXTRACT(EPOCH FROM NEW.scheduled_at) AS FLOAT)::text));
33
- END IF;
34
- RETURN NULL;
35
- END;
37
+ execute <<~ENDFUNC
38
+ CREATE OR REPLACE FUNCTION skiplock.notify_jobs() RETURNS TRIGGER AS $$
39
+ DECLARE
40
+ record RECORD;
41
+ BEGIN
42
+ record = NEW;
43
+ IF (TG_OP = 'DELETE') THEN
44
+ record = OLD;
45
+ IF (record.finished_at IS NOT NULL OR record.expired_at IS NOT NULL) THEN
46
+ RETURN NULL;
47
+ END IF;
48
+ INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
49
+ ELSIF (record.running = TRUE) THEN
50
+ IF (record.executions IS NULL) THEN
51
+ INSERT INTO skiplock.counters (day,dispatches) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET dispatches = skiplock.counters.dispatches + 1;
52
+ ELSE
53
+ INSERT INTO skiplock.counters (day,retries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET retries = skiplock.counters.retries + 1;
54
+ END IF;
55
+ ELSIF (record.finished_at IS NOT NULL) THEN
56
+ INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
57
+ ELSIF (record.expired_at IS NOT NULL) THEN
58
+ INSERT INTO skiplock.counters (day,expiries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET expiries = skiplock.counters.expiries + 1;
59
+ ELSIF (record.executions IS NOT NULL AND record.scheduled_at IS NOT NULL) THEN
60
+ INSERT INTO skiplock.counters (day,failures) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET failures = skiplock.counters.failures + 1;
61
+ END IF;
62
+ PERFORM pg_notify('skiplock::jobs', CONCAT(TG_OP,',',record.id::TEXT,',',record.worker_id::TEXT,',',record.queue_name,',',record.running::TEXT,',',CAST(EXTRACT(EPOCH FROM record.expired_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.finished_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM CASE WHEN record.scheduled_at IS NULL THEN record.updated_at ELSE record.scheduled_at END) AS FLOAT)::TEXT));
63
+ RETURN NULL;
64
+ END;
36
65
  $$ LANGUAGE plpgsql
37
- )
38
- execute "CREATE TRIGGER notify_job AFTER INSERT OR UPDATE ON skiplock.jobs FOR EACH ROW EXECUTE PROCEDURE skiplock.notify()"
39
- execute "CREATE INDEX jobs_index ON skiplock.jobs(scheduled_at ASC NULLS FIRST, priority ASC NULLS LAST, created_at ASC) WHERE expired_at IS NULL AND finished_at IS NULL"
40
- execute "CREATE INDEX jobs_retry_index ON skiplock.jobs(scheduled_at) WHERE executions IS NOT NULL AND expired_at IS NULL AND finished_at IS NULL"
66
+ ENDFUNC
67
+ execute <<~ENDFUNC
68
+ CREATE OR REPLACE FUNCTION skiplock.notify_workers() RETURNS TRIGGER AS $$
69
+ DECLARE
70
+ record RECORD;
71
+ BEGIN
72
+ IF (TG_OP = 'DELETE') THEN
73
+ record = OLD;
74
+ ELSE
75
+ record = NEW;
76
+ END IF;
77
+ PERFORM pg_notify('skiplock::workers', CONCAT(TG_OP,',',record.id::TEXT,',',record.hostname,',',record.capacity,',',record.pid,',',record.ppid));
78
+ RETURN NULL;
79
+ END;
80
+ $$ LANGUAGE plpgsql;
81
+ ENDFUNC
82
+ execute "CREATE TRIGGER notify_job AFTER INSERT OR UPDATE OR DELETE ON skiplock.jobs FOR EACH ROW EXECUTE PROCEDURE skiplock.notify_jobs()"
83
+ execute "CREATE TRIGGER notify_worker AFTER INSERT OR UPDATE OR DELETE ON skiplock.workers FOR EACH ROW EXECUTE PROCEDURE skiplock.notify_workers()"
84
+ execute "CREATE INDEX jobs_index ON skiplock.jobs(scheduled_at ASC NULLS FIRST, priority ASC NULLS LAST, created_at ASC) WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL"
85
+ 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"
41
86
  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"
42
87
  execute "CREATE UNIQUE INDEX jobs_unique_cron_index ON skiplock.jobs (job_class) WHERE cron IS NOT NULL"
43
88
  end
data/lib/skiplock.rb CHANGED
@@ -1,21 +1,35 @@
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/notification'
8
7
  require 'skiplock/job'
8
+ require 'skiplock/manager'
9
9
  require 'skiplock/worker'
10
10
  require 'skiplock/version'
11
11
 
12
12
  module Skiplock
13
13
  Settings = {
14
- 'logging' => :timestamp,
14
+ 'logging' => 'timestamp',
15
15
  'min_threads' => 1,
16
16
  'max_threads' => 5,
17
17
  'max_retries' => 20,
18
+ 'notification' => 'auto',
18
19
  'purge_completion' => true,
20
+ 'queues' => {
21
+ 'default' => 200,
22
+ 'mailers' => 100
23
+ },
19
24
  'workers' => 0
20
25
  }
26
+ mattr_reader :on_errors, default: []
27
+
28
+ def self.on_error(&block)
29
+ @@on_errors << block
30
+ end
31
+
32
+ def self.table_name_prefix
33
+ 'skiplock.'
34
+ end
21
35
  end
@@ -0,0 +1,4 @@
1
+ module Skiplock
2
+ class Counter < ActiveRecord::Base
3
+ end
4
+ end
@@ -1,6 +1,7 @@
1
1
  module Skiplock
2
2
  class Dispatcher
3
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
4
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)
5
6
  @master = master
6
7
  if @master
@@ -8,6 +9,7 @@ module Skiplock
8
9
  else
9
10
  @worker_num = worker_num
10
11
  end
12
+ @last_dispatch_at = 0
11
13
  @next_schedule_at = Time.now.to_f
12
14
  @running = true
13
15
  end
@@ -15,18 +17,23 @@ module Skiplock
15
17
  def run
16
18
  Thread.new do
17
19
  Rails.application.reloader.wrap do
18
- sleep(0.1) while @running && !Rails.application.initialized?
19
- Process.setproctitle("skiplock-#{@master ? 'master' : 'worker[' + @worker_num.to_s + ']'}") if Settings['workers'] > 0
20
+ sleep(1) while @running && !Rails.application.initialized?
21
+ Rails.application.eager_load!
22
+ Process.setproctitle("skiplock-#{@master ? 'master[0]' : 'worker[' + @worker_num.to_s + ']'}") if Settings['workers'] > 0 && !Rails.env.development?
20
23
  ActiveRecord::Base.connection_pool.with_connection do |connection|
21
24
  connection.exec_query('LISTEN "skiplock::jobs"')
22
- hostname = `hostname`.strip
25
+ hostname = `hostname -f`.strip
23
26
  @worker = Worker.create!(pid: Process.pid, ppid: (@master ? nil : Process.ppid), capacity: Settings['max_threads'], hostname: hostname)
24
27
  if @master
25
- # get worker ids that were not shutdown properly on the host
28
+ connection.exec_query('LISTEN "skiplock::workers"')
29
+ Dir.mkdir('tmp/skiplock') unless Dir.exist?('tmp/skiplock')
30
+ check_sync_errors
31
+ # get dead worker ids
26
32
  dead_worker_ids = Worker.where(hostname: hostname).where.not(pid: @worker_pids).ids
27
33
  if dead_worker_ids.count > 0
28
34
  # reset orphaned jobs of the dead worker ids for retry
29
- Job.where(running: true, worker_id: dead_worker_ids).update_all(running: false, worker_id: nil)
35
+ Job.where(running: true).where(worker_id: dead_worker_ids).update_all(running: false, worker_id: nil)
36
+ # remove dead workers
30
37
  Worker.where(id: dead_worker_ids).delete_all
31
38
  end
32
39
  # reset retries schedules on startup
@@ -41,43 +48,60 @@ module Skiplock
41
48
  connection.reconnect!
42
49
  sleep(0.5)
43
50
  connection.exec_query('LISTEN "skiplock::jobs"')
44
- @next_schedule_at = Time.now
51
+ connection.exec_query('LISTEN "skiplock::workers"') if @master
52
+ @next_schedule_at = Time.now.to_f
45
53
  end
54
+ check_sync_errors
46
55
  error = false
47
56
  end
48
- if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
49
- @executor.post { do_work }
50
- end
51
- notifications = []
57
+ notifications = { 'skiplock::jobs' => [], 'skiplock::workers' => [] }
52
58
  connection.raw_connection.wait_for_notify(0.1) do |channel, pid, payload|
53
- notifications << payload if payload
59
+ notifications[channel] << payload if payload
54
60
  loop do
55
61
  payload = connection.raw_connection.notifies
56
62
  break unless @running && payload
57
- notifications << payload[:extra]
63
+ notifications[payload[:relname]] << payload[:extra]
58
64
  end
59
- notifications.each do |n|
65
+ notifications['skiplock::jobs'].each do |n|
60
66
  op, id, worker_id, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
61
- next if op == 'DELETE' || running == 'true' || expired_at || finished_at
62
- if scheduled_at.to_f <= Time.now.to_f
67
+ if @master
68
+ # TODO: report job status to action cable
69
+ end
70
+ next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0 || scheduled_at.to_f < @last_dispatch_at
71
+ if scheduled_at.to_f < Time.now.to_f
63
72
  @next_schedule_at = Time.now.to_f
64
73
  elsif scheduled_at.to_f < @next_schedule_at
65
- @next_schedule_at = time.to_f
74
+ @next_schedule_at = scheduled_at.to_f
66
75
  end
67
76
  end
77
+ if @master
78
+ # TODO: report worker status to action cable
79
+ notifications['skiplock::workers'].each do |n|
80
+ end
81
+ end
82
+ end
83
+ if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
84
+ @executor.post { do_work }
68
85
  end
69
86
  rescue Exception => ex
70
- # TODO: Report exception
87
+ # most likely error with database connection
88
+ STDERR.puts ex.message
89
+ STDERR.puts ex.backtrace
90
+ Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
71
91
  error = true
72
92
  t = Time.now
73
93
  while @running
74
94
  sleep(0.5)
75
95
  break if Time.now - t > 5
76
- end
96
+ end
97
+ @last_exception = ex
77
98
  end
78
- sleep(0.1)
99
+ sleep(0.2)
79
100
  end
80
101
  connection.exec_query('UNLISTEN *')
102
+ @executor.shutdown
103
+ @executor.wait_for_termination if @wait
104
+ @worker.delete if @worker
81
105
  end
82
106
  end
83
107
  end
@@ -85,23 +109,37 @@ module Skiplock
85
109
 
86
110
  def shutdown(wait: true)
87
111
  @running = false
88
- @executor.shutdown
89
- @executor.wait_for_termination if wait
90
- @worker.delete if @worker
112
+ @wait = wait
91
113
  end
92
114
 
93
115
  private
94
116
 
117
+ def check_sync_errors
118
+ # get performed jobs that could not sync with database
119
+ Dir.glob('tmp/skiplock/*').each do |f|
120
+ job_from_db = Job.find_by(id: File.basename(f), running: true)
121
+ disposed = true
122
+ if job_from_db
123
+ job, ex = YAML.load_file(f) rescue nil
124
+ disposed = job.dispose(ex)
125
+ end
126
+ File.delete(f) if disposed
127
+ end
128
+ end
129
+
95
130
  def do_work
96
131
  while @running
97
- result = Job.dispatch(worker_id: @worker.id)
98
- next if result.is_a?(Hash)
132
+ @last_dispatch_at = Time.now.to_f - 1 # 1 second allowance for timedrift
133
+ result = Job.dispatch(queues_order_query: @queues_order_query, worker_id: @worker.id)
134
+ next if result.is_a?(Job)
99
135
  @next_schedule_at = result if result.is_a?(Float)
100
136
  break
101
137
  end
102
- rescue Exception => e
103
- puts "Exception => #{e.message} #{e.backtrace}"
104
- # TODO: Report exception
138
+ rescue Exception => ex
139
+ STDERR.puts ex.message
140
+ STDERR.puts ex.backtrace
141
+ Skiplock.on_errors.each { |p| p.call(ex, @last_exception) }
142
+ @last_exception = ex
105
143
  end
106
144
  end
107
145
  end
data/lib/skiplock/job.rb CHANGED
@@ -1,16 +1,13 @@
1
1
  module Skiplock
2
2
  class Job < ActiveRecord::Base
3
- self.table_name = 'skiplock.jobs'
4
-
5
- # Return: Attributes hash of the Job if it was executed; otherwise returns the next Job's schedule time in FLOAT
6
- def self.dispatch(worker_id: nil)
3
+ def self.dispatch(queues_order_query: nil, worker_id: nil)
7
4
  self.connection.exec_query('BEGIN')
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, priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").first
5
+ job = self.find_by_sql("SELECT id, scheduled_at FROM #{self.table_name} WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL ORDER BY scheduled_at ASC NULLS FIRST,#{queues_order_query ? ' CASE ' + queues_order_query + ' ELSE NULL END ASC NULLS LAST,' : ''} priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").first
9
6
  if job.nil? || job.scheduled_at.to_f > Time.now.to_f
10
7
  self.connection.exec_query('END')
11
8
  return (job ? job.scheduled_at.to_f : Float::INFINITY)
12
9
  end
13
- 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
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
  self.connection.exec_query('END')
15
12
  job.data ||= {}
16
13
  job.exception_executions ||= {}
@@ -21,40 +18,13 @@ module Skiplock
21
18
  ActiveJob::Base.execute(job_data)
22
19
  rescue Exception => ex
23
20
  end
24
- job.running = false
25
- if ex
26
- # TODO: report exception
27
- job.exception_executions["[#{ex.class.name}]"] = (job.exception_executions["[#{ex.class.name}]"] || 0) + 1 unless job.exception_executions.key?('activejob_retry')
28
- if job.executions >= Settings['max_retries'] || job.exception_executions.key?('activejob_retry')
29
- job.expired_at = Time.now
30
- job.save!
31
- else
32
- job.scheduled_at = Time.now + (5 * 2**job.executions)
33
- job.save!
34
- end
35
- elsif job.exception_executions.key?('activejob_retry')
36
- job.save!
37
- elsif job['cron']
38
- job.data['last_cron_run'] = Time.now.utc.to_s
39
- next_cron_at = Cron.next_schedule_at(job['cron'])
40
- if next_cron_at
41
- job.executions = 1
42
- job.exception_executions = nil
43
- job.scheduled_at = Time.at(next_cron_at)
44
- job.save!
45
- else
46
- job.delete
47
- end
48
- elsif Settings['purge_completion']
49
- job.delete
50
- else
51
- job.finished_at = Time.now
52
- job.exception_executions = nil
53
- job.save!
54
- end
55
- job
21
+ job.dispose(ex)
56
22
  ensure
57
- Thread.current[:skiplock_dispatch_data] = nil
23
+ Thread.current[:skiplock_dispatch_job] = nil
24
+ end
25
+
26
+ def self.enqueue(activejob)
27
+ self.enqueue_at(activejob, nil)
58
28
  end
59
29
 
60
30
  def self.enqueue_at(activejob, timestamp)
@@ -65,8 +35,49 @@ module Skiplock
65
35
  Thread.current[:skiplock_dispatch_job].scheduled_at = timestamp
66
36
  Thread.current[:skiplock_dispatch_job]
67
37
  else
68
- 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)
38
+ Job.create!(id: activejob.job_id, job_class: activejob.class.name, queue_name: activejob.queue_name, locale: activejob.locale, timezone: activejob.timezone, priority: activejob.priority, data: { 'arguments' => activejob.serialize['arguments'] }, scheduled_at: timestamp)
39
+ end
40
+ end
41
+
42
+ def dispose(ex)
43
+ self.running = false
44
+ self.updated_at = (Time.now > self.updated_at ? Time.now : self.updated_at + 1)
45
+ if ex
46
+ self.exception_executions["[#{ex.class.name}]"] = (self.exception_executions["[#{ex.class.name}]"] || 0) + 1 unless self.exception_executions.key?('activejob_retry')
47
+ if self.executions >= Settings['max_retries'] || self.exception_executions.key?('activejob_retry')
48
+ self.expired_at = Time.now
49
+ self.save!
50
+ else
51
+ self.scheduled_at = Time.now + (5 * 2**self.executions)
52
+ self.save!
53
+ end
54
+ Skiplock.on_errors.each { |p| p.call(ex) }
55
+ elsif self.exception_executions.try(:key?, 'activejob_retry')
56
+ self.save!
57
+ elsif self.cron
58
+ self.data ||= {}
59
+ self.data['crons'] = (self.data['crons'] || 0) + 1
60
+ self.data['last_cron_at'] = Time.now.utc.to_s
61
+ next_cron_at = Cron.next_schedule_at(self.cron)
62
+ if next_cron_at
63
+ self.executions = nil
64
+ self.exception_executions = nil
65
+ self.scheduled_at = Time.at(next_cron_at)
66
+ self.save!
67
+ else
68
+ self.delete
69
+ end
70
+ elsif Settings['purge_completion']
71
+ self.delete
72
+ else
73
+ self.finished_at = Time.now
74
+ self.exception_executions = nil
75
+ self.save!
69
76
  end
77
+ self
78
+ rescue
79
+ File.write("tmp/skiplock/#{self.id}", [self, ex].to_yaml)
80
+ nil
70
81
  end
71
82
  end
72
83
  end
@@ -1,8 +1,23 @@
1
1
  module Skiplock
2
2
  class Manager
3
- def self.start(standalone: false)
4
- load_settings
5
- return unless standalone || (caller.any?{|l| l =~ %r{/rack/}} && (Settings['workers'] == 0 || Rails.env.development?))
3
+ def self.start(standalone: false, restart: false, workers: nil, max_retries: nil, max_threads: nil, min_threads: nil, logging: nil)
4
+ unless Settings.frozen?
5
+ load_settings
6
+ Settings['logging'] = logging if logging
7
+ Settings['max_retries'] = max_retries if max_retries
8
+ Settings['max_threads'] = max_threads if max_threads
9
+ Settings['min_threads'] = min_threads if min_threads
10
+ Settings['workers'] = workers if workers
11
+ Settings['max_retries'] = 20 if Settings['max_retries'] > 20
12
+ Settings['max_retries'] = 0 if Settings['max_retries'] < 0
13
+ Settings['max_threads'] = 1 if Settings['max_threads'] < 1
14
+ Settings['max_threads'] = 20 if Settings['max_threads'] > 20
15
+ Settings['min_threads'] = 0 if Settings['min_threads'] < 0
16
+ Settings['workers'] = 0 if Settings['workers'] < 0
17
+ Settings['workers'] = 1 if standalone && Settings['workers'] <= 0
18
+ Settings.freeze
19
+ end
20
+ return unless standalone || restart || (caller.any?{|l| l =~ %r{/rack/}} && Settings['workers'] == 0)
6
21
  if standalone
7
22
  self.standalone
8
23
  else
@@ -10,44 +25,60 @@ module Skiplock
10
25
  @thread = @dispatcher.run
11
26
  at_exit { self.shutdown }
12
27
  end
28
+ ActiveJob::Base.logger = nil
13
29
  end
14
30
 
15
31
  def self.shutdown(wait: true)
16
32
  if @dispatcher && @thread
17
33
  @dispatcher.shutdown(wait: wait)
18
34
  @thread.join
35
+ @dispatcher = nil
36
+ @thread = nil
19
37
  end
20
38
  end
21
39
 
22
40
  private
23
41
 
24
42
  def self.load_settings
25
- return if Settings.frozen?
26
43
  config = YAML.load_file('config/skiplock.yml') rescue {}
27
44
  Settings.merge!(config)
28
- Settings['max_retries'] = 20 if Settings['max_retries'] > 20
29
- Settings['max_retries'] = 0 if Settings['max_retries'] < 0
30
- Settings['max_threads'] = 1 if Settings['max_threads'] < 1
31
- Settings['max_threads'] = 20 if Settings['max_threads'] > 20
32
- Settings['min_threads'] = 0 if Settings['min_threads'] < 0
33
- Settings['workers'] = 0 if Settings['workers'] < 0
34
- Settings.freeze
45
+ Settings['queues'].values.each { |v| raise 'Queue value must be an integer' unless v.is_a?(Integer) } if Settings['queues'].is_a?(Hash)
46
+ @notification = Settings['notification'] = Settings['notification'].to_s.downcase
47
+ if @notification == 'auto'
48
+ if defined?(Airbrake)
49
+ @notification = 'airbrake'
50
+ elsif defined?(Bugsnag)
51
+ @notification = 'bugsnag'
52
+ elsif defined?(ExceptionNotifier)
53
+ @notification = 'exception_notification'
54
+ else
55
+ puts "Unable to detect any known exception notification gem. Please define custom 'on_error' callback function and disable 'auto' notification in 'config/skiplock.yml'"
56
+ exit
57
+ end
58
+ end
59
+ case @notification
60
+ when 'airbrake'
61
+ raise 'airbrake gem not found' unless defined?(Airbrake)
62
+ Skiplock.on_error do |ex, previous|
63
+ Airbrake.notify_sync(ex) unless ex.backtrace == previous.try(:backtrace)
64
+ end
65
+ when 'bugsnag'
66
+ raise 'bugsnag gem not found' unless defined?(Bugsnag)
67
+ Skiplock.on_error do |ex, previous|
68
+ Bugsnag.notify(ex) unless ex.backtrace == previous.try(:backtrace)
69
+ end
70
+ when 'exception_notification'
71
+ raise 'exception_notification gem not found' unless defined?(ExceptionNotifier)
72
+ Skiplock.on_error do |ex, previous|
73
+ ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace)
74
+ end
75
+ end
76
+ rescue Exception => e
77
+ STDERR.puts "Invalid configuration 'config/skiplock.yml': #{e.message}"
78
+ exit
35
79
  end
36
80
 
37
81
  def self.standalone
38
- title = "Skiplock version: #{Skiplock::VERSION} (Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
39
- puts "-"*(title.length)
40
- puts title
41
- puts "-"*(title.length)
42
- puts "Additional workers: #{Settings['workers']}"
43
- puts " Purge completion: #{Settings['purge_completion']}"
44
- puts " Max retries: #{Settings['max_retries']}"
45
- puts " Min threads: #{Settings['min_threads']}"
46
- puts " Max threads: #{Settings['max_threads']}"
47
- puts " Environment: #{Rails.env}"
48
- puts " Logging: #{Settings['logging']}"
49
- puts " PID: #{Process.pid}"
50
- puts "-"*(title.length)
51
82
  if Settings['logging']
52
83
  log_timestamp = (Settings['logging'].to_s == 'timestamp')
53
84
  logfile = File.open('log/skiplock.log', 'a')
@@ -61,14 +92,29 @@ module Skiplock
61
92
  Rails.logger.reopen('/dev/null')
62
93
  Rails.logger.extend(ActiveSupport::Logger.broadcast(logger))
63
94
  end
95
+ title = "Skiplock version: #{Skiplock::VERSION} (Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
96
+ puts "-"*(title.length)
97
+ puts title
98
+ puts "-"*(title.length)
99
+ puts "Purge completion: #{Settings['purge_completion']}"
100
+ puts " Notification: #{Settings['notification']}#{(' (' + @notification + ')') if Settings['notification'] == 'auto'}"
101
+ puts " Max retries: #{Settings['max_retries']}"
102
+ puts " Min threads: #{Settings['min_threads']}"
103
+ puts " Max threads: #{Settings['max_threads']}"
104
+ puts " Environment: #{Rails.env}"
105
+ puts " Logging: #{Settings['logging']}"
106
+ puts " Workers: #{Settings['workers']}"
107
+ puts " Queues: #{Settings['queues'].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if Settings['queues'].is_a?(Hash)
108
+ puts " PID: #{Process.pid}"
109
+ puts "-"*(title.length)
64
110
  parent_id = Process.pid
65
111
  shutdown = false
66
112
  Signal.trap("INT") { shutdown = true }
67
113
  Signal.trap("TERM") { shutdown = true }
68
114
  worker_pids = []
69
- Settings['workers'].times do |n|
115
+ (Settings['workers']-1).times do |n|
70
116
  worker_pids << fork do
71
- dispatcher = Dispatcher.new(master: false, worker_num: n)
117
+ dispatcher = Dispatcher.new(master: false, worker_num: n+1)
72
118
  thread = dispatcher.run
73
119
  loop do
74
120
  sleep 0.5
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.4'
2
+ VERSION = Version = '1.0.9'
3
3
  end
4
4
 
@@ -1,5 +1,4 @@
1
1
  module Skiplock
2
2
  class Worker < ActiveRecord::Base
3
- self.table_name = 'skiplock.workers'
4
3
  end
5
4
  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.4
4
+ version: 1.0.9
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-22 00:00:00.000000000 Z
11
+ date: 2021-05-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -82,11 +82,11 @@ 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
87
88
  - lib/skiplock/job.rb
88
89
  - lib/skiplock/manager.rb
89
- - lib/skiplock/notification.rb
90
90
  - lib/skiplock/version.rb
91
91
  - lib/skiplock/worker.rb
92
92
  homepage: https://github.com/vtt/skiplock
@@ -108,8 +108,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
108
108
  - !ruby/object:Gem::Version
109
109
  version: '0'
110
110
  requirements: []
111
- rubyforge_project:
112
- rubygems_version: 2.7.6
111
+ rubygems_version: 3.0.3
113
112
  signing_key:
114
113
  specification_version: 4
115
114
  summary: ActiveJob Queue Adapter for PostgreSQL
@@ -1,5 +0,0 @@
1
- module Skiplock
2
- class Notification
3
- end
4
- end
5
-