skiplock 1.0.3 → 1.0.8

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: 9731224db727e3ebe4b32b94b4edff6bf7a53faca14be5a6f5d7cc20a02836e9
4
- data.tar.gz: 9c3cb7ccf0d98d7e8ec4bcd21e33269504d166804f0679fe7d286290527a8457
3
+ metadata.gz: 2ee6a8ff68af1a1029a26db98867457a798c4fb6aacf0a2a2816efd3ef2b977b
4
+ data.tar.gz: 2c75fb21c79346f4525b8634b28fb326505acb7ab61795fe7d291c3faa75244f
5
5
  SHA512:
6
- metadata.gz: 560b30ea96a8f255811b1c8a0beb7e161ba1edd2dde238e52cd31625e1a4e4d729ff24f3d4d37e4a29fbb2c3e2543e86b9cb1720553cacab346ac30db2970aea
7
- data.tar.gz: '093b4ee02119973dbd1c8c905d2ccbcc1d6d9efad440ba23ba8fc05a8a2a3580ec7a7d3c7c47d5f6077b789a440e4c46e455f81a5c10fd032b01ffefae906a0e'
6
+ metadata.gz: c34fa2c27f9fc8bcfbe6c54a30de8645418dfe7c13e8db9ea8c30352f75abd9ab9b04d1a294aea7f2a9f33772d9cc5ed96839bd63d1eacbda17749758b4755b2
7
+ data.tar.gz: 03cdbef1a70a64fb188258dc44049f9323f53cb9c2e64f8a79a9c8a4a17ca8aea2e7ca718ceb3bc78cc08fd61d6dbd5c3f104f653a9645132ef31f73d22883da
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Skiplock
2
2
 
3
- Skiplock is a background job queuing system that improves the performance and reliability of the job executions while providing the same ACID guarantees as the rest of your data. It is designed for Active Jobs with Ruby on Rails using PostgreSQL database adapter, but it can be modified to work with other frameworks easily.
3
+ `Skiplock` is a background job queuing system that improves the performance and reliability of the job executions while providing the same ACID guarantees as the rest of your data. It is designed for Active Jobs with Ruby on Rails using PostgreSQL database adapter, but it can be modified to work with other frameworks easily.
4
4
 
5
5
  It only uses the `LISTEN/NOTIFY/SKIP LOCKED` features provided natively on PostgreSQL 9.5+ to efficiently and reliably dispatch jobs to worker processes and threads ensuring that each job can be completed successfully **only once**. No other polling or timer is needed.
6
6
 
@@ -14,7 +14,7 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
14
14
 
15
15
  ## Installation
16
16
 
17
- 1. Add `skiplock` to your application's Gemfile:
17
+ 1. Add `Skiplock` to your application's Gemfile:
18
18
 
19
19
  ```ruby
20
20
  gem 'skiplock'
@@ -26,7 +26,7 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
26
26
  $ bundle install
27
27
  ```
28
28
 
29
- 3. Run the Skiplock install generator. This will generate a configuration file and database migration to store the job records:
29
+ 3. Run the `Skiplock` install generator. This will generate a configuration file and database migration to store the job records:
30
30
 
31
31
  ```bash
32
32
  $ rails g skiplock:install
@@ -46,7 +46,7 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
46
46
  # config/application.rb
47
47
  config.active_job.queue_adapter = :skiplock
48
48
  ```
49
- 2. Skiplock configuration
49
+ 2. `Skiplock` configuration
50
50
  ```yaml
51
51
  # config/skiplock.yml
52
52
  ---
@@ -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,11 +66,13 @@ 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 priorites; 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
78
  before_fork do
@@ -74,14 +80,10 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
74
80
  Skiplock::Manager.shutdown
75
81
  end
76
82
 
77
- on_worker_boot do
78
- # ...
79
- Skiplock::Manager.start
80
- end
81
-
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 runs in Puma master's process only
86
+ Skiplock::Manager.start if (worker_index + 1) == @options[:workers]
85
87
  end
86
88
  ```
87
89
 
@@ -93,18 +95,24 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
93
95
  ```
94
96
  - Skiplock supports all ActiveJob features:
95
97
  ```ruby
96
- MyJob.set(wait: 5.minutes, priority: 10).perform_later(1,2,3)
98
+ MyJob.set(queue: 'my_queue', wait: 5.minutes, priority: 10).perform_later(1,2,3)
97
99
  ```
98
100
  - Outside of Rails application, queue the jobs by inserting the job records directly to the database table eg:
99
101
  ```sql
100
102
  INSERT INTO skiplock.jobs(job_class) VALUES ('MyJob');
101
103
  ```
102
- - Or with scheduling, priority and arguments:
104
+ - Or with scheduling, priority, queue and arguments:
103
105
  ```sql
104
- INSERT INTO skiplock.jobs(job_class,priority,scheduled_at,data) VALUES ('MyJob',10,NOW()+INTERVAL '5 min','{"arguments":[1,2,3]}');
106
+ 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]}');
105
107
  ```
108
+ ## Queues priority vs Job priority
109
+ *Why do queues have 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 dispatches jobs without priorities (eg. Mail Delivery will queue as **mailers** with no priority)
113
+
106
114
  ## Cron system
107
- 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.
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
109
117
 
110
118
  ```ruby
@@ -123,7 +131,7 @@ Skiplock provides the capability to setup cron jobs for running tasks periodical
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 rescue blocks 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
@@ -144,7 +152,16 @@ Once the retry attempt limit configured in ActiveJob has been reached, the contr
144
152
  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.
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`. A customized function can also be called whenever an exception occurs; this can be configured in an initializer as below:
156
+ ```ruby
157
+ # config/initializers/skiplock.rb
158
+ Skiplock.on_error = -> (ex, previous = nil) do
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
+ ```
148
165
 
149
166
  ## Contributing
150
167
 
@@ -2,33 +2,64 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
2
2
  def up
3
3
  execute 'CREATE SCHEMA skiplock'
4
4
  create_table 'skiplock.jobs', id: :uuid do |t|
5
+ t.uuid :worker_id, index: true
5
6
  t.string :job_class, null: false
6
- t.string :cron
7
- t.string :queue_name
7
+ t.string :queue_name, index: true
8
8
  t.string :locale
9
9
  t.string :timezone
10
+ t.string :cron
10
11
  t.integer :priority
11
12
  t.integer :executions
12
13
  t.jsonb :exception_executions
13
14
  t.jsonb :data
14
- t.boolean :running, null: false, default: false
15
+ t.boolean :running, null: false, default: false, index: true
15
16
  t.timestamp :expired_at
16
17
  t.timestamp :finished_at
17
18
  t.timestamp :scheduled_at
18
19
  t.timestamps null: false, default: -> { 'now()' }
19
20
  end
20
- execute %(CREATE OR REPLACE FUNCTION skiplock.notify() RETURNS TRIGGER AS $$
21
- BEGIN
22
- IF (NEW.finished_at IS NULL AND NEW.expired_at IS NULL) THEN
23
- 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));
24
- END IF;
25
- RETURN NULL;
26
- END;
21
+ create_table 'skiplock.workers', id: :uuid do |t|
22
+ t.integer :pid, null: false, index: true
23
+ t.integer :ppid, index: true
24
+ t.integer :capacity, null: false
25
+ t.string :hostname, null: false, index: true
26
+ t.jsonb :data
27
+ t.timestamps null: false, index: true, default: -> { 'now()' }
28
+ end
29
+ execute <<~ENDFUNC
30
+ CREATE OR REPLACE FUNCTION skiplock.notify_jobs() RETURNS TRIGGER AS $$
31
+ DECLARE
32
+ record RECORD;
33
+ BEGIN
34
+ IF (TG_OP = 'DELETE') THEN
35
+ record = OLD;
36
+ ELSE
37
+ record = NEW;
38
+ 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 CASE WHEN record.scheduled_at IS NULL THEN record.updated_at ELSE record.scheduled_at END) AS FLOAT)::TEXT));
40
+ RETURN NULL;
41
+ END;
27
42
  $$ LANGUAGE plpgsql
28
- )
29
- execute "CREATE TRIGGER notify_job AFTER INSERT OR UPDATE ON skiplock.jobs FOR EACH ROW EXECUTE PROCEDURE skiplock.notify()"
30
- execute "CREATE INDEX jobs_index ON skiplock.jobs(scheduled_at ASC NULLS FIRST, priority ASC NULLS LAST, created_at ASC) WHERE running = 'f' AND expired_at IS NULL AND finished_at IS NULL"
31
- execute "CREATE INDEX jobs_retry_index ON skiplock.jobs(scheduled_at) WHERE running = 'f' AND executions IS NOT NULL AND expired_at IS NULL AND finished_at IS NULL"
43
+ ENDFUNC
44
+ execute <<~ENDFUNC
45
+ CREATE OR REPLACE FUNCTION skiplock.notify_workers() RETURNS TRIGGER AS $$
46
+ DECLARE
47
+ record RECORD;
48
+ BEGIN
49
+ IF (TG_OP = 'DELETE') THEN
50
+ record = OLD;
51
+ ELSE
52
+ record = NEW;
53
+ END IF;
54
+ PERFORM pg_notify('skiplock::workers', CONCAT(TG_OP,',',record.id::TEXT,',',record.hostname,',',record.capacity,',',record.pid,',',record.ppid));
55
+ RETURN NULL;
56
+ END;
57
+ $$ LANGUAGE plpgsql;
58
+ ENDFUNC
59
+ execute "CREATE TRIGGER notify_job AFTER INSERT OR UPDATE OR DELETE ON skiplock.jobs FOR EACH ROW EXECUTE PROCEDURE skiplock.notify_jobs()"
60
+ execute "CREATE TRIGGER notify_worker AFTER INSERT OR UPDATE OR DELETE ON skiplock.workers FOR EACH ROW EXECUTE PROCEDURE skiplock.notify_workers()"
61
+ 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"
62
+ 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"
32
63
  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"
33
64
  execute "CREATE UNIQUE INDEX jobs_unique_cron_index ON skiplock.jobs (job_class) WHERE cron IS NOT NULL"
34
65
  end
data/lib/skiplock.rb CHANGED
@@ -4,17 +4,23 @@ require 'active_record'
4
4
  require 'skiplock/cron'
5
5
  require 'skiplock/dispatcher'
6
6
  require 'skiplock/manager'
7
- require 'skiplock/notification'
8
7
  require 'skiplock/job'
8
+ require 'skiplock/worker'
9
9
  require 'skiplock/version'
10
10
 
11
11
  module Skiplock
12
12
  Settings = {
13
- 'logging' => :timestamp,
13
+ 'logging' => 'timestamp',
14
14
  'min_threads' => 1,
15
15
  'max_threads' => 5,
16
16
  'max_retries' => 20,
17
+ 'notification' => 'auto',
17
18
  'purge_completion' => true,
19
+ 'queues' => {
20
+ 'default' => 200,
21
+ 'mailers' => 100
22
+ },
18
23
  'workers' => 0
19
24
  }
25
+ mattr_accessor :on_error
20
26
  end
@@ -1,68 +1,120 @@
1
1
  module Skiplock
2
2
  class Dispatcher
3
- def initialize(master: true)
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
7
+ if @master
8
+ @worker_pids = worker_pids + [ Process.pid ]
9
+ else
10
+ @worker_num = worker_num
11
+ end
12
+ @last_dispatch_at = 0
6
13
  @next_schedule_at = Time.now.to_f
7
14
  @running = true
8
15
  end
9
16
 
10
17
  def run
11
18
  Thread.new do
12
- Rails.application.reloader.wrap do
13
- sleep(0.1) while @running && !Rails.application.initialized?
14
- ActiveRecord::Base.connection_pool.with_connection do |connection|
15
- connection.exec_query('LISTEN skiplock')
16
- if @master
17
- # reset retries schedules on startup
18
- 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)
19
- Cron.setup
19
+ sleep(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
+ connection.exec_query('LISTEN "skiplock::workers"')
27
+ if File.exists?('tmp/cache/skiplock')
28
+ # get performed jobs that could not sync with database
29
+ job_ids = File.read('tmp/cache/skiplock').split("\n")
30
+ if Settings['purge_completion']
31
+ Job.where(id: job_ids).delete_all
32
+ else
33
+ Job.where(id: job_ids).update_all(running: false, finished_at: File.mtime('tmp/cache/skiplock'), updated_at: Time.now)
34
+ end
35
+ File.delete('tmp/cache/skiplock')
20
36
  end
21
- error = false
22
- while @running
23
- begin
24
- if error
25
- unless connection.active?
26
- connection.reconnect!
27
- sleep(0.5)
28
- connection.exec_query('LISTEN skiplock')
29
- @next_schedule_at = Time.now
30
- end
31
- error = false
37
+ # get dead worker ids
38
+ dead_worker_ids = Worker.where(hostname: hostname).where.not(pid: @worker_pids).ids
39
+ if dead_worker_ids.count > 0
40
+ # reset orphaned jobs of the dead worker ids for retry
41
+ Job.where(running: true).where(worker_id: dead_worker_ids).update_all(running: false, worker_id: nil)
42
+ # remove dead workers
43
+ Worker.where(id: dead_worker_ids).delete_all
44
+ end
45
+ # reset retries schedules on startup
46
+ 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)
47
+ Cron.setup
48
+ end
49
+ error = false
50
+ while @running
51
+ begin
52
+ if error
53
+ unless connection.active?
54
+ connection.reconnect!
55
+ sleep(0.5)
56
+ connection.exec_query('LISTEN "skiplock::jobs"')
57
+ connection.exec_query('LISTEN "skiplock::workers"') if @master
58
+ @next_schedule_at = Time.now.to_f
59
+ end
60
+ error = false
61
+ end
62
+ if Job::Errors.keys.count > 0
63
+ completed_ids = Job::Errors.keys.map { |k| k if Job::Errors[k] }.compact
64
+ if Settings['purge_completion'] && completed_ids.count > 0
65
+ Job.where(id: completed_ids, running: true).delete_all
66
+ elsif completed_ids.count > 0
67
+ Job.where(id: completed_ids, running: true).update_all(running: false, finished_at: Time.now, updated_at: Time.now)
32
68
  end
33
- if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
34
- @executor.post { do_work }
69
+ orphaned_ids = Job::Errors.keys.map { |k| k unless Job::Errors[k] }.compact
70
+ Job.where(id: orphaned_ids, running: true).update_all(running: false, worker_id: nil, scheduled_at: (Time.now + 10), updated_at: Time.now) if orphaned_ids.count > 0
71
+ Job::Errors.clear
72
+ end
73
+ notifications = { 'skiplock::jobs' => [], 'skiplock::workers' => [] }
74
+ connection.raw_connection.wait_for_notify(0.1) do |channel, pid, payload|
75
+ notifications[channel] << payload if payload
76
+ loop do
77
+ payload = connection.raw_connection.notifies
78
+ break unless @running && payload
79
+ notifications[payload[:relname]] << payload[:extra]
35
80
  end
36
- notifications = []
37
- connection.raw_connection.wait_for_notify(0.1) do |channel, pid, payload|
38
- notifications << payload if payload
39
- loop do
40
- payload = connection.raw_connection.notifies
41
- break unless @running && payload
42
- notifications << payload[:extra]
81
+ notifications['skiplock::jobs'].each do |n|
82
+ op, id, worker_id, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
83
+ if @master
84
+ # TODO: report job status to action cable
43
85
  end
44
- notifications.each do |n|
45
- op, id, queue, priority, time = n.split(',')
46
- if time.to_f <= Time.now.to_f
47
- @next_schedule_at = Time.now.to_f
48
- elsif time.to_f < @next_schedule_at
49
- @next_schedule_at = time.to_f
50
- end
86
+ next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0 || scheduled_at.to_f < @last_dispatch_at
87
+ if scheduled_at.to_f < Time.now.to_f
88
+ @next_schedule_at = Time.now.to_f
89
+ elsif scheduled_at.to_f < @next_schedule_at
90
+ @next_schedule_at = scheduled_at.to_f
51
91
  end
52
92
  end
53
- rescue Exception => ex
54
- # TODO: Report exception
55
- error = true
56
- timestamp = Time.now
57
- while @running
58
- sleep(0.5)
59
- break if Time.now - timestamp > 10
60
- end
93
+ if @master
94
+ # TODO: report worker status to action cable
95
+ notifications['skiplock::workers'].each do |n|
96
+ end
97
+ end
98
+ end
99
+ if Time.now.to_f >= @next_schedule_at && @executor.remaining_capacity > 0
100
+ @executor.post { do_work }
101
+ end
102
+ rescue Exception => ex
103
+ # most likely error with database connection
104
+ STDERR.puts ex.message
105
+ STDERR.puts ex.backtrace
106
+ Skiplock.on_error.call(ex, @last_exception) if Skiplock.on_error.is_a?(Proc)
107
+ error = true
108
+ t = Time.now
109
+ while @running
110
+ sleep(0.5)
111
+ break if Time.now - t > 5
61
112
  end
62
- sleep(0.1)
113
+ @last_exception = ex
63
114
  end
64
- connection.exec_query('UNLISTEN *')
115
+ sleep(0.2)
65
116
  end
117
+ connection.exec_query('UNLISTEN *')
66
118
  end
67
119
  end
68
120
  end
@@ -71,22 +123,24 @@ module Skiplock
71
123
  @running = false
72
124
  @executor.shutdown
73
125
  @executor.wait_for_termination if wait
126
+ @worker.delete if @worker
74
127
  end
75
128
 
76
129
  private
77
130
 
78
131
  def do_work
79
- connection = ActiveRecord::Base.connection_pool.checkout
80
132
  while @running
81
- result = Job.dispatch(connection: connection)
82
- next if result.is_a?(Hash)
133
+ @last_dispatch_at = Time.now.to_f
134
+ result = Job.dispatch(queues_order_query: @queues_order_query, worker_id: @worker.id)
135
+ next if result.is_a?(Job)
83
136
  @next_schedule_at = result if result.is_a?(Float)
84
137
  break
85
138
  end
86
- rescue Exception => e
87
- # TODO: Report exception
88
- ensure
89
- ActiveRecord::Base.connection_pool.checkin(connection)
139
+ rescue Exception => ex
140
+ STDERR.puts ex.message
141
+ STDERR.puts ex.backtrace
142
+ Skiplock.on_error.call(ex, @last_exception) if Skiplock.on_error.is_a?(Proc)
143
+ @last_exception = ex
90
144
  end
91
145
  end
92
146
  end
data/lib/skiplock/job.rb CHANGED
@@ -1,71 +1,82 @@
1
1
  module Skiplock
2
2
  class Job < ActiveRecord::Base
3
3
  self.table_name = 'skiplock.jobs'
4
+ Errors = Concurrent::Map.new
4
5
 
5
- # Accept: An active ActiveRecord database connection (eg. ActiveRecord::Base.connection)
6
- # The connection should be checked out using ActiveRecord::Base.connection_pool.checkout, and be checked
7
- # in using ActiveRecord::Base.conection_pool.checkin once all of the job dispatches have been completed.
8
- # *** IMPORTANT: This connection cannot be shared with the job's execution
9
- #
10
- # Return: Attributes hash of the Job if it was executed; otherwise returns the next Job's schedule time in FLOAT
11
- def self.dispatch(connection: ActiveRecord::Base.connection)
12
- connection.exec_query('BEGIN')
13
- job = connection.exec_query("SELECT * FROM #{self.table_name} WHERE running = 'f' 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
14
- if job && job['scheduled_at'].to_f <= Time.now.to_f # found job ready to perform
15
- # update the job to mark it in progress in case database server goes down during job execution
16
- connection.exec_query("UPDATE #{self.table_name} SET running = 't' WHERE id = '#{job['id']}'")
17
- connection.exec_query('END') # close the transaction commit the state of job in progress
18
- executions = (job['executions'] || 0) + 1
19
- exceptions = job['exception_executions'] ? JSON.parse(job['exception_executions']) : {}
20
- data = job['data'] ? JSON.parse(job['data']) : {}
21
- job_data = job.slice('job_class', 'queue_name', 'locale', 'timezone', 'priority', 'executions').merge('job_id' => job['id'], 'exception_executions' => exceptions, 'enqueued_at' => job['updated_at']).merge(data)
22
- Thread.current[:skiplock_dispatch_data] = job_data
23
- begin
24
- ActiveJob::Base.execute(job_data)
25
- rescue Exception => ex
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)
14
+ 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
+ job.data ||= {}
18
+ job.exception_executions ||= {}
19
+ 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
+ job.executions = (job.executions || 0) + 1
21
+ Thread.current[:skiplock_dispatch_job] = job
22
+ begin
23
+ ActiveJob::Base.execute(job_data)
24
+ rescue Exception => ex
25
+ 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!
26
36
  end
27
- if ex
28
- # TODO: report exception
29
- exceptions["[#{ex.class.name}]"] = (exceptions["[#{ex.class.name}]"] || 0) + 1 unless exceptions.key?('activejob_retry')
30
- if executions >= Settings['max_retries'] || exceptions.key?('activejob_retry')
31
- connection.exec_query("UPDATE #{self.table_name} SET running = 'f', executions = #{executions}, exception_executions = '#{connection.quote_string(exceptions.to_json.to_s)}', expired_at = NOW(), updated_at = NOW() WHERE id = '#{job['id']}' RETURNING *").first
32
- else
33
- timestamp = Time.now + (5 * 2**executions)
34
- connection.exec_query("UPDATE #{self.table_name} SET running = 'f', executions = #{executions}, exception_executions = '#{connection.quote_string(exceptions.to_json.to_s)}', scheduled_at = TO_TIMESTAMP(#{timestamp.to_f}), updated_at = NOW() WHERE id = '#{job['id']}' RETURNING *").first
35
- end
36
- elsif exceptions.key?('activejob_retry')
37
- connection.exec_query("UPDATE #{self.table_name} SET running = 'f', executions = #{job_data['executions']}, exception_executions = '#{connection.quote_string(job_data['exception_executions'].to_json.to_s)}', scheduled_at = TO_TIMESTAMP(#{job_data['scheduled_at'].to_f}), updated_at = NOW() WHERE id = '#{job['id']}' RETURNING *").first
38
- elsif job['cron']
39
- data['last_cron_run'] = Time.now.utc.to_s
40
- next_cron_at = Cron.next_schedule_at(job['cron'])
41
- if next_cron_at
42
- connection.exec_query("UPDATE #{self.table_name} SET running = 'f', scheduled_at = TO_TIMESTAMP(#{next_cron_at}), executions = 1, exception_executions = NULL, data = '#{connection.quote_string(data.to_json.to_s)}', updated_at = NOW() WHERE id = '#{job['id']}' RETURNING *").first
43
- else
44
- connection.exec_query("DELETE FROM #{self.table_name} WHERE id = '#{job['id']}' RETURNING *").first
45
- end
46
- elsif Settings['purge_completion']
47
- connection.exec_query("DELETE FROM #{self.table_name} WHERE id = '#{job['id']}' RETURNING *").first
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
48
  else
49
- connection.exec_query("UPDATE #{self.table_name} SET running = 'f', executions = #{executions}, exception_executions = NULL, finished_at = NOW(), updated_at = NOW() WHERE id = '#{job['id']}' RETURNING *").first
49
+ job.delete
50
50
  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!
57
+ 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')
51
63
  else
52
- connection.exec_query('END')
53
- job ? job['scheduled_at'].to_f : Float::INFINITY
64
+ Errors[job.id] = false
54
65
  end
66
+ raise ex
55
67
  ensure
56
- Thread.current[:skiplock_dispatch_data] = nil
68
+ Thread.current[:skiplock_dispatch_job] = nil
57
69
  end
58
70
 
59
- def self.enqueue_at(job, timestamp)
60
- if Thread.current[:skiplock_dispatch_data]
61
- job.exception_executions['activejob_retry'] = true
62
- Thread.current[:skiplock_dispatch_data]['executions'] = job.executions
63
- Thread.current[:skiplock_dispatch_data]['exception_executions'] = job.exception_executions
64
- Thread.current[:skiplock_dispatch_data]['scheduled_at'] = Time.at(timestamp)
65
- self.new(Thread.current[:skiplock_dispatch_data].slice(*self.column_names).merge(id: job.job_id))
71
+ def self.enqueue_at(activejob, timestamp)
72
+ timestamp = Time.at(timestamp) if timestamp
73
+ if Thread.current[:skiplock_dispatch_job].try(:id) == activejob.job_id
74
+ Thread.current[:skiplock_dispatch_job].exception_executions = activejob.exception_executions.merge('activejob_retry' => true)
75
+ Thread.current[:skiplock_dispatch_job].executions = activejob.executions
76
+ Thread.current[:skiplock_dispatch_job].scheduled_at = timestamp
77
+ Thread.current[:skiplock_dispatch_job]
66
78
  else
67
- timestamp = Time.at(timestamp) if timestamp
68
- Job.create!(id: job.job_id, job_class: job.class.name, queue_name: job.queue_name, locale: job.locale, timezone: job.timezone, priority: job.priority, executions: job.executions, data: { 'arguments' => job.arguments }, scheduled_at: timestamp)
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)
69
80
  end
70
81
  end
71
82
  end
@@ -1,8 +1,22 @@
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.freeze
18
+ end
19
+ return unless standalone || restart || (caller.any?{|l| l =~ %r{/rack/}} && (Settings['workers'] == 0 || Rails.env.development?))
6
20
  if standalone
7
21
  self.standalone
8
22
  else
@@ -10,44 +24,52 @@ module Skiplock
10
24
  @thread = @dispatcher.run
11
25
  at_exit { self.shutdown }
12
26
  end
27
+ ActiveJob::Base.logger = nil
13
28
  end
14
29
 
15
30
  def self.shutdown(wait: true)
16
31
  if @dispatcher && @thread
17
32
  @dispatcher.shutdown(wait: wait)
18
33
  @thread.join
34
+ @dispatcher = nil
35
+ @thread = nil
19
36
  end
20
37
  end
21
38
 
22
39
  private
23
40
 
24
41
  def self.load_settings
25
- return if Settings.frozen?
26
42
  config = YAML.load_file('config/skiplock.yml') rescue {}
27
43
  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
44
+ Settings['queues'].values.each { |v| raise 'Queue value must be an integer' unless v.is_a?(Integer) } if Settings['queues'].is_a?(Hash)
45
+ case Settings['notification'].to_s.downcase
46
+ when 'auto'
47
+ if defined?(Airbrake)
48
+ Skiplock.on_error = -> (ex, previous = nil) { Airbrake.notify_sync(ex) unless ex.backtrace == previous.try(:backtrace) }
49
+ elsif defined?(Bugsnag)
50
+ Skiplock.on_error = -> (ex, previous = nil) { Bugsnag.notify(ex) unless ex.backtrace == previous.try(:backtrace) }
51
+ elsif defined?(ExceptionNotifier)
52
+ Skiplock.on_error = -> (ex, previous = nil) { ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace) }
53
+ else
54
+ puts "Unable to detect any known exception notification gem. Please define custom 'on_error' function and disable notification in 'config/skiplock.yml'"
55
+ exit
56
+ end
57
+ when 'airbrake'
58
+ raise 'airbrake gem not found' unless defined?(Airbrake)
59
+ Skiplock.on_error = -> (ex, previous = nil) { Airbrake.notify_sync(ex) unless ex.backtrace == previous.try(:backtrace) }
60
+ when 'bugsnag'
61
+ raise 'bugsnag gem not found' unless defined?(Bugsnag)
62
+ Skiplock.on_error = -> (ex, previous = nil) { Bugsnag.notify(ex) unless ex.backtrace == previous.try(:backtrace) }
63
+ when 'exception_notification'
64
+ raise 'exception_notification gem not found' unless defined?(ExceptionNotifier)
65
+ Skiplock.on_error = -> (ex, previous = nil) { ExceptionNotifier.notify_exception(ex) unless ex.backtrace == previous.try(:backtrace) }
66
+ end
67
+ rescue Exception => e
68
+ STDERR.puts "Invalid configuration 'config/skiplock.yml': #{e.message}"
69
+ exit
35
70
  end
36
71
 
37
72
  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
73
  if Settings['logging']
52
74
  log_timestamp = (Settings['logging'].to_s == 'timestamp')
53
75
  logfile = File.open('log/skiplock.log', 'a')
@@ -61,14 +83,29 @@ module Skiplock
61
83
  Rails.logger.reopen('/dev/null')
62
84
  Rails.logger.extend(ActiveSupport::Logger.broadcast(logger))
63
85
  end
86
+ title = "Skiplock version: #{Skiplock::VERSION} (Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
87
+ puts "-"*(title.length)
88
+ puts title
89
+ puts "-"*(title.length)
90
+ puts "Purge completion: #{Settings['purge_completion']}"
91
+ puts " Notification: #{Settings['notification']}"
92
+ puts " Max retries: #{Settings['max_retries']}"
93
+ puts " Min threads: #{Settings['min_threads']}"
94
+ puts " Max threads: #{Settings['max_threads']}"
95
+ puts " Environment: #{Rails.env}"
96
+ puts " Logging: #{Settings['logging']}"
97
+ puts " Workers: #{Settings['workers']}"
98
+ puts " Queues: #{Settings['queues'].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if Settings['queues'].is_a?(Hash)
99
+ puts " PID: #{Process.pid}"
100
+ puts "-"*(title.length)
64
101
  parent_id = Process.pid
65
102
  shutdown = false
66
103
  Signal.trap("INT") { shutdown = true }
67
104
  Signal.trap("TERM") { shutdown = true }
68
- Settings['workers'].times do |w|
69
- fork do
70
- Process.setproctitle("skiplock-worker[#{w+1}]")
71
- dispatcher = Dispatcher.new(master: false)
105
+ worker_pids = []
106
+ (Settings['workers']-1).times do |n|
107
+ worker_pids << fork do
108
+ dispatcher = Dispatcher.new(master: false, worker_num: n+1)
72
109
  thread = dispatcher.run
73
110
  loop do
74
111
  sleep 0.5
@@ -80,8 +117,7 @@ module Skiplock
80
117
  end
81
118
  end
82
119
  sleep 0.1
83
- Process.setproctitle("skiplock-master")
84
- dispatcher = Dispatcher.new
120
+ dispatcher = Dispatcher.new(worker_pids: worker_pids)
85
121
  thread = dispatcher.run
86
122
  loop do
87
123
  sleep 0.5
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.3'
2
+ VERSION = Version = '1.0.8'
3
3
  end
4
4
 
@@ -0,0 +1,5 @@
1
+ module Skiplock
2
+ class Worker < ActiveRecord::Base
3
+ self.table_name = 'skiplock.workers'
4
+ end
5
+ 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.3
4
+ version: 1.0.8
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-17 00:00:00.000000000 Z
11
+ date: 2021-04-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -86,8 +86,8 @@ files:
86
86
  - lib/skiplock/dispatcher.rb
87
87
  - lib/skiplock/job.rb
88
88
  - lib/skiplock/manager.rb
89
- - lib/skiplock/notification.rb
90
89
  - lib/skiplock/version.rb
90
+ - lib/skiplock/worker.rb
91
91
  homepage: https://github.com/vtt/skiplock
92
92
  licenses:
93
93
  - MIT
@@ -107,7 +107,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
107
107
  - !ruby/object:Gem::Version
108
108
  version: '0'
109
109
  requirements: []
110
- rubygems_version: 3.0.3
110
+ rubygems_version: 3.1.4
111
111
  signing_key:
112
112
  specification_version: 4
113
113
  summary: ActiveJob Queue Adapter for PostgreSQL
@@ -1,5 +0,0 @@
1
- module Skiplock
2
- class Notification
3
- end
4
- end
5
-