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 +4 -4
- data/README.md +37 -20
- data/lib/generators/skiplock/templates/migration.rb.erb +45 -14
- data/lib/skiplock.rb +8 -2
- data/lib/skiplock/dispatcher.rb +107 -53
- data/lib/skiplock/job.rb +66 -55
- data/lib/skiplock/manager.rb +66 -30
- data/lib/skiplock/version.rb +1 -1
- data/lib/skiplock/worker.rb +5 -0
- metadata +4 -4
- data/lib/skiplock/notification.rb +0 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2ee6a8ff68af1a1029a26db98867457a798c4fb6aacf0a2a2816efd3ef2b977b
|
4
|
+
data.tar.gz: 2c75fb21c79346f4525b8634b28fb326505acb7ab61795fe7d291c3faa75244f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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 `
|
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
|
-
- **
|
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
|
-
|
78
|
-
#
|
79
|
-
Skiplock
|
80
|
-
|
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 :
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
30
|
-
|
31
|
-
|
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' =>
|
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
|
data/lib/skiplock/dispatcher.rb
CHANGED
@@ -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.
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
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
|
-
|
34
|
-
|
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
|
-
|
38
|
-
|
39
|
-
|
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
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
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
|
-
|
113
|
+
@last_exception = ex
|
63
114
|
end
|
64
|
-
|
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
|
-
|
82
|
-
|
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 =>
|
87
|
-
|
88
|
-
|
89
|
-
|
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
|
-
#
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
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
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
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
|
-
|
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
|
-
|
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[:
|
68
|
+
Thread.current[:skiplock_dispatch_job] = nil
|
57
69
|
end
|
58
70
|
|
59
|
-
def self.enqueue_at(
|
60
|
-
if
|
61
|
-
|
62
|
-
Thread.current[:
|
63
|
-
Thread.current[:
|
64
|
-
Thread.current[:
|
65
|
-
|
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
|
-
|
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
|
data/lib/skiplock/manager.rb
CHANGED
@@ -1,8 +1,22 @@
|
|
1
1
|
module Skiplock
|
2
2
|
class Manager
|
3
|
-
def self.start(standalone: false)
|
4
|
-
|
5
|
-
|
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['
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
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
|
-
|
69
|
-
|
70
|
-
|
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
|
-
|
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
|
data/lib/skiplock/version.rb
CHANGED
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
|
+
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-
|
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.
|
110
|
+
rubygems_version: 3.1.4
|
111
111
|
signing_key:
|
112
112
|
specification_version: 4
|
113
113
|
summary: ActiveJob Queue Adapter for PostgreSQL
|