skiplock 1.0.22 → 1.1.0
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 +11 -1
- data/bin/skiplock +1 -0
- data/lib/generators/skiplock/templates/migration.rb.erb +25 -19
- data/lib/skiplock/cron.rb +1 -0
- data/lib/skiplock/job.rb +5 -4
- data/lib/skiplock/manager.rb +22 -16
- data/lib/skiplock/version.rb +1 -1
- data/lib/skiplock/worker.rb +55 -51
- data/lib/skiplock.rb +10 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71cf3158d71dfe424c218da13a55d785365bee9027f827bb1498ba5e0110fda9
|
|
4
|
+
data.tar.gz: 1512863ab863b392f84d67a137959227a823ac33dd9ca8e1ab8e87c63fe04adf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79ef005a284b8aabfff1e1435bba2019a3b3698f714803f6521562f143b3d5f4f6536991a040bbd44574a52fa3be2df7ac565c37cccf4da40da95fe4b150b1c6
|
|
7
|
+
data.tar.gz: 1f7fd443df703c98133b66838c6d6d49a9cfa3b54d33cafaff15d59fd3a538591400fdb917ccfb5ec4b8a2831d88b6a1f85ef622ecb7566842f2f761e97bba9f
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
|
|
|
7
|
-
The library is quite small compared to other PostgreSQL job queues (eg. *delay_job*, *queue_classic*, *que*, *good_job*) with less than
|
|
7
|
+
The library is quite small compared to other PostgreSQL job queues (eg. *delay_job*, *queue_classic*, *que*, *good_job*) with less than 600 lines of codes; and it still provides similar set of features and more...
|
|
8
8
|
|
|
9
9
|
#### Compatibility:
|
|
10
10
|
|
|
@@ -50,11 +50,13 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
|
|
|
50
50
|
```yaml
|
|
51
51
|
# config/skiplock.yml (default settings)
|
|
52
52
|
---
|
|
53
|
+
graceful_shutdown: 15
|
|
53
54
|
min_threads: 1
|
|
54
55
|
max_threads: 10
|
|
55
56
|
max_retries: 20
|
|
56
57
|
logfile: skiplock.log
|
|
57
58
|
loglevel: info
|
|
59
|
+
namespace:
|
|
58
60
|
notification: custom
|
|
59
61
|
extensions: false
|
|
60
62
|
purge_completion: true
|
|
@@ -64,11 +66,13 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
|
|
|
64
66
|
workers: 0
|
|
65
67
|
```
|
|
66
68
|
Available configuration options are:
|
|
69
|
+
- **graceful_shutdown** (*integer*): sets the number of seconds to wait for jobs to finish before being killed during shutdown
|
|
67
70
|
- **min_threads** (*integer*): sets minimum number of threads staying idle
|
|
68
71
|
- **max_threads** (*integer*): sets the maximum number of threads allowed to run jobs
|
|
69
72
|
- **max_retries** (*integer*): sets the maximum attempt a job will be retrying before it is marked expired. See `Retry system` for more details
|
|
70
73
|
- **logfile** (*string*): filename for skiplock logs; empty logfile will disable logging
|
|
71
74
|
- **loglevel** (*string*): sets logging level (`debug, info, warn, error, fatal, unknown`)
|
|
75
|
+
- **namespace** (*string*): sets namespace for jobs (workers will only process jobs of specified namespace)
|
|
72
76
|
- **notification** (*string*): sets the library to be used for notifying errors and exceptions (`auto, airbrake, bugsnag, exception_notification, custom`); using `auto` will detect library if available. See `Notification system` for more details
|
|
73
77
|
- **extensions** (*multi*): enable or disable the class method extension. See `ClassMethod extension` for more details
|
|
74
78
|
- **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); queued jobs can manually override using `purge` option
|
|
@@ -85,6 +89,8 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
|
|
|
85
89
|
Usage: skiplock [options]
|
|
86
90
|
-e, --environment STRING Rails environment
|
|
87
91
|
-l, --logfile STRING Log filename
|
|
92
|
+
-L, --loglevel STRING Log level (debug, info, warn, error, fatal, unknown)
|
|
93
|
+
-n, --namespace STRING Job namespace
|
|
88
94
|
-s, --graceful-shutdown NUM Number of seconds to wait for graceful shutdown
|
|
89
95
|
-r, --max-retries NUM Number of maxixum retries
|
|
90
96
|
-t, --max-threads NUM Number of maximum threads
|
|
@@ -237,6 +243,10 @@ Code examples of gathering counters information:
|
|
|
237
243
|
```ruby
|
|
238
244
|
Skiplock::Counter.sum(:expiries)
|
|
239
245
|
```
|
|
246
|
+
- get all information in one query
|
|
247
|
+
```ruby
|
|
248
|
+
Skiplock::Counter.pluck("sum(completions), sum(dispatches), sum(expiries), sum(failures), sum(retries)").first
|
|
249
|
+
```
|
|
240
250
|
|
|
241
251
|
## Contributing
|
|
242
252
|
|
data/bin/skiplock
CHANGED
|
@@ -8,6 +8,7 @@ begin
|
|
|
8
8
|
opts.on('-e', '--environment STRING', String, 'Rails environment')
|
|
9
9
|
opts.on('-l', '--logfile STRING', String, 'Log filename')
|
|
10
10
|
opts.on('-L', '--loglevel STRING', String, 'Log level (debug, info, warn, error, fatal, unknown)')
|
|
11
|
+
opts.on('-n', '--namespace STRING', String, 'Job namespace')
|
|
11
12
|
opts.on('-s', '--graceful-shutdown NUM', Integer, 'Number of seconds to wait for graceful shutdown')
|
|
12
13
|
opts.on('-r', '--max-retries NUM', Integer, 'Number of maxixum retries')
|
|
13
14
|
opts.on('-t', '--max-threads NUM', Integer, 'Number of maximum threads')
|
|
@@ -7,11 +7,14 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
|
|
|
7
7
|
t.integer :expiries, null: false, default: 0
|
|
8
8
|
t.integer :failures, null: false, default: 0
|
|
9
9
|
t.integer :retries, null: false, default: 0
|
|
10
|
-
t.
|
|
10
|
+
t.string :namespace, null: false, default: '', index: true
|
|
11
|
+
t.date :day, null: false
|
|
12
|
+
t.index [ :namespace, :day ], unique: true
|
|
11
13
|
end
|
|
12
14
|
create_table 'skiplock.jobs', id: :uuid do |t|
|
|
13
15
|
t.uuid :worker_id, index: true
|
|
14
16
|
t.string :job_class, null: false
|
|
17
|
+
t.string :namespace, null: false, default: '', index: true
|
|
15
18
|
t.string :queue_name, index: true
|
|
16
19
|
t.string :locale
|
|
17
20
|
t.string :timezone
|
|
@@ -31,6 +34,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
|
|
|
31
34
|
t.integer :sid, null: false
|
|
32
35
|
t.integer :capacity, null: false
|
|
33
36
|
t.string :hostname, null: false, index: true
|
|
37
|
+
t.string :namespace, null: false, default: '', index: true
|
|
34
38
|
t.boolean :master, null: false, default: false, index: true
|
|
35
39
|
t.jsonb :data
|
|
36
40
|
t.timestamps null: false, index: true, default: -> { 'now()' }
|
|
@@ -41,27 +45,29 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
|
|
|
41
45
|
record RECORD;
|
|
42
46
|
BEGIN
|
|
43
47
|
record = NEW;
|
|
44
|
-
IF
|
|
48
|
+
IF TG_OP = 'DELETE' THEN
|
|
45
49
|
record = OLD;
|
|
46
|
-
IF
|
|
47
|
-
INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
|
|
50
|
+
IF record.running IS TRUE THEN
|
|
51
|
+
INSERT INTO skiplock.counters (namespace,day,completions) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET completions = skiplock.counters.completions + 1;
|
|
48
52
|
END IF;
|
|
49
|
-
ELSIF
|
|
50
|
-
IF
|
|
51
|
-
IF
|
|
52
|
-
INSERT INTO skiplock.counters (day,retries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET retries = skiplock.counters.retries + 1;
|
|
53
|
+
ELSIF TG_OP = 'UPDATE' THEN
|
|
54
|
+
IF OLD.running IS FALSE AND record.running IS TRUE THEN
|
|
55
|
+
IF record.executions > 0 THEN
|
|
56
|
+
INSERT INTO skiplock.counters (namespace,day,retries) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET retries = skiplock.counters.retries + 1;
|
|
53
57
|
ELSE
|
|
54
|
-
INSERT INTO skiplock.counters (day,dispatches) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET dispatches = skiplock.counters.dispatches + 1;
|
|
58
|
+
INSERT INTO skiplock.counters (namespace,day,dispatches) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET dispatches = skiplock.counters.dispatches + 1;
|
|
59
|
+
END IF;
|
|
60
|
+
ELSIF OLD.finished_at IS NULL AND record.finished_at IS NOT NULL THEN
|
|
61
|
+
INSERT INTO skiplock.counters (namespace,day,completions) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET completions = skiplock.counters.completions + 1;
|
|
62
|
+
ELSIF OLD.running IS TRUE AND record.running IS FALSE THEN
|
|
63
|
+
IF record.expired_at IS NOT NULL THEN
|
|
64
|
+
INSERT INTO skiplock.counters (namespace,day,expiries) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET expiries = skiplock.counters.expiries + 1;
|
|
65
|
+
ELSE
|
|
66
|
+
INSERT INTO skiplock.counters (namespace,day,failures) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET failures = skiplock.counters.failures + 1;
|
|
55
67
|
END IF;
|
|
56
|
-
ELSIF (OLD.finished_at IS NULL AND record.finished_at IS NOT NULL) THEN
|
|
57
|
-
INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
|
|
58
|
-
ELSIF (OLD.running = TRUE AND record.running = FALSE AND record.expired_at IS NOT NULL) THEN
|
|
59
|
-
INSERT INTO skiplock.counters (day,expiries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET expiries = skiplock.counters.expiries + 1;
|
|
60
|
-
ELSIF (OLD.running = TRUE AND record.running = FALSE AND record.expired_at IS NULL AND record.finished_at IS NULL) THEN
|
|
61
|
-
INSERT INTO skiplock.counters (day,failures) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET failures = skiplock.counters.failures + 1;
|
|
62
68
|
END IF;
|
|
63
69
|
END IF;
|
|
64
|
-
PERFORM pg_notify('skiplock::jobs', CONCAT(TG_OP,',',record.id::TEXT,',',record.worker_id::TEXT,',',record.job_class,',',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));
|
|
70
|
+
PERFORM pg_notify('skiplock::jobs', CONCAT(TG_OP,',',record.id::TEXT,',',record.worker_id::TEXT,',',record.namespace,',',record.job_class,',',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));
|
|
65
71
|
RETURN NULL;
|
|
66
72
|
END;
|
|
67
73
|
$$ LANGUAGE plpgsql
|
|
@@ -71,12 +77,12 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
|
|
|
71
77
|
DECLARE
|
|
72
78
|
record RECORD;
|
|
73
79
|
BEGIN
|
|
74
|
-
IF
|
|
80
|
+
IF TG_OP = 'DELETE' THEN
|
|
75
81
|
record = OLD;
|
|
76
82
|
ELSE
|
|
77
83
|
record = NEW;
|
|
78
84
|
END IF;
|
|
79
|
-
PERFORM pg_notify('skiplock::workers', CONCAT(TG_OP,',',record.id::TEXT,',',record.hostname,',',record.master::TEXT,',',record.capacity,',',record.pid,',',record.sid,',',CAST(EXTRACT(EPOCH FROM record.created_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.updated_at) AS FLOAT)::TEXT));
|
|
85
|
+
PERFORM pg_notify('skiplock::workers', CONCAT(TG_OP,',',record.id::TEXT,',',record.namespace,',',record.hostname,',',record.master::TEXT,',',record.capacity,',',record.pid,',',record.sid,',',CAST(EXTRACT(EPOCH FROM record.created_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.updated_at) AS FLOAT)::TEXT));
|
|
80
86
|
RETURN NULL;
|
|
81
87
|
END;
|
|
82
88
|
$$ LANGUAGE plpgsql;
|
|
@@ -87,7 +93,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
|
|
|
87
93
|
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"
|
|
88
94
|
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"
|
|
89
95
|
execute "CREATE UNIQUE INDEX jobs_unique_cron_index ON skiplock.jobs (job_class) WHERE cron IS NOT NULL"
|
|
90
|
-
execute "CREATE UNIQUE INDEX workers_unique_master_index ON skiplock.workers(hostname) WHERE master = TRUE"
|
|
96
|
+
execute "CREATE UNIQUE INDEX workers_unique_master_index ON skiplock.workers(hostname,namespace) WHERE master = TRUE"
|
|
91
97
|
end
|
|
92
98
|
|
|
93
99
|
def down
|
data/lib/skiplock/cron.rb
CHANGED
data/lib/skiplock/job.rb
CHANGED
|
@@ -3,14 +3,15 @@ module Skiplock
|
|
|
3
3
|
self.implicit_order_column = 'updated_at'
|
|
4
4
|
attribute :activejob_error
|
|
5
5
|
attribute :exception
|
|
6
|
-
attribute :max_retries
|
|
7
|
-
attribute :purge
|
|
6
|
+
attribute :max_retries
|
|
7
|
+
attribute :purge
|
|
8
8
|
belongs_to :worker, inverse_of: :jobs, required: false
|
|
9
9
|
|
|
10
10
|
def self.dispatch(purge_completion: true, max_retries: 20)
|
|
11
|
+
namespace_query = Skiplock.namespace.nil? ? "namespace IS NULL" : "namespace = '#{Skiplock.namespace}'"
|
|
11
12
|
job = nil
|
|
12
13
|
self.connection.transaction do
|
|
13
|
-
job = self.find_by_sql("SELECT id, scheduled_at FROM skiplock.jobs 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
|
|
14
|
+
job = self.find_by_sql("SELECT id, scheduled_at FROM skiplock.jobs WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL AND #{namespace_query} ORDER BY scheduled_at ASC NULLS FIRST, priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").first
|
|
14
15
|
return if job.nil? || job.scheduled_at.to_f > Time.now.to_f
|
|
15
16
|
job = self.find_by_sql("UPDATE skiplock.jobs SET running = TRUE, updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
|
|
16
17
|
end
|
|
@@ -33,7 +34,7 @@ module Skiplock
|
|
|
33
34
|
Thread.current[:skiplock_job]
|
|
34
35
|
else
|
|
35
36
|
serialize = activejob.serialize
|
|
36
|
-
self.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'], 'options' => options }, 'scheduled_at' => timestamp))
|
|
37
|
+
self.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'], 'options' => options }, 'namespace' => Skiplock.namespace, 'scheduled_at' => timestamp))
|
|
37
38
|
end
|
|
38
39
|
end
|
|
39
40
|
|
data/lib/skiplock/manager.rb
CHANGED
|
@@ -10,7 +10,8 @@ module Skiplock
|
|
|
10
10
|
elsif @config[:extensions].is_a?(Array)
|
|
11
11
|
@config[:extensions].each { |n| n.constantize.__send__(:extend, Skiplock::Extension) if n.safe_constantize }
|
|
12
12
|
end
|
|
13
|
-
|
|
13
|
+
ActiveJob::Base.__send__(:include, Skiplock::Patch)
|
|
14
|
+
(caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0) ? async : Cron.setup
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def async
|
|
@@ -18,6 +19,7 @@ module Skiplock
|
|
|
18
19
|
configure
|
|
19
20
|
Worker.cleanup(@hostname)
|
|
20
21
|
@worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
|
|
22
|
+
Cron.setup if @worker.master
|
|
21
23
|
@worker.start(**@config)
|
|
22
24
|
at_exit { @worker.shutdown }
|
|
23
25
|
rescue Exception => ex
|
|
@@ -42,7 +44,8 @@ module Skiplock
|
|
|
42
44
|
ActiveRecord::Base.connection.disconnect! if @config[:workers] > 1
|
|
43
45
|
(@config[:workers] - 1).times do |n|
|
|
44
46
|
fork do
|
|
45
|
-
sleep 1
|
|
47
|
+
sleep(0.25*n + 1)
|
|
48
|
+
ActiveRecord::Base.establish_connection
|
|
46
49
|
worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname, master: false)
|
|
47
50
|
worker.start(worker_num: n + 1, **@config)
|
|
48
51
|
loop do
|
|
@@ -73,24 +76,25 @@ module Skiplock
|
|
|
73
76
|
@logger.info "-"*(title.length)
|
|
74
77
|
@logger.info title
|
|
75
78
|
@logger.info "-"*(title.length)
|
|
76
|
-
@logger.info "ClassMethod extensions: #{@config[:extensions]}"
|
|
77
|
-
@logger.info "
|
|
78
|
-
@logger.info "
|
|
79
|
-
@logger.info "
|
|
80
|
-
@logger.info "
|
|
81
|
-
@logger.info "
|
|
82
|
-
@logger.info "
|
|
83
|
-
@logger.info "
|
|
84
|
-
@logger.info "
|
|
85
|
-
@logger.info "
|
|
86
|
-
@logger.info "
|
|
87
|
-
@logger.info "
|
|
79
|
+
@logger.info " ClassMethod extensions: #{@config[:extensions]}"
|
|
80
|
+
@logger.info " Purge completion: #{@config[:purge_completion]}"
|
|
81
|
+
@logger.info " Notification: #{@config[:notification]}"
|
|
82
|
+
@logger.info " Max retries: #{@config[:max_retries]}"
|
|
83
|
+
@logger.info " Min threads: #{@config[:min_threads]}"
|
|
84
|
+
@logger.info " Max threads: #{@config[:max_threads]}"
|
|
85
|
+
@logger.info " Environment: #{Rails.env}"
|
|
86
|
+
@logger.info " Namespace: #{@config[:namespace] || '(nil)'}"
|
|
87
|
+
@logger.info " Loglevel: #{@config[:loglevel]}"
|
|
88
|
+
@logger.info " Logfile: #{@config[:logfile] || '(disabled)'}"
|
|
89
|
+
@logger.info " Workers: #{@config[:workers]}"
|
|
90
|
+
@logger.info " Queues: #{@config[:queues].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if @config[:queues].is_a?(Hash)
|
|
91
|
+
@logger.info " PID: #{Process.pid}"
|
|
88
92
|
@logger.info "-"*(title.length)
|
|
89
93
|
@logger.warn "[Skiplock] Custom notification has no registered 'on_error' callback" if Skiplock.on_errors.count == 0
|
|
90
94
|
end
|
|
91
95
|
|
|
92
96
|
def configure
|
|
93
|
-
@hostname = Socket.
|
|
97
|
+
@hostname = "#{`hostname -f`.strip}|#{Socket.ip_address_list.reject(&:ipv4_loopback?).reject(&:ipv6?).map(&:ip_address).join('|')}"
|
|
94
98
|
@config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
|
|
95
99
|
@config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
|
|
96
100
|
@config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
|
|
@@ -131,6 +135,7 @@ module Skiplock
|
|
|
131
135
|
else
|
|
132
136
|
@config[:notification] = 'custom'
|
|
133
137
|
end
|
|
138
|
+
Skiplock.namespace = @config[:namespace]
|
|
134
139
|
Skiplock.on_errors.freeze
|
|
135
140
|
end
|
|
136
141
|
|
|
@@ -145,6 +150,7 @@ module Skiplock
|
|
|
145
150
|
end
|
|
146
151
|
if @config[:standalone]
|
|
147
152
|
Rails.logger.reopen('/dev/null') rescue Rails.logger.reopen('NUL') # supports Windows NUL device
|
|
153
|
+
Rails.logger.level = @logger.level
|
|
148
154
|
Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
|
|
149
155
|
end
|
|
150
156
|
rescue Exception => ex
|
|
@@ -153,4 +159,4 @@ module Skiplock
|
|
|
153
159
|
Skiplock.on_errors.each { |p| p.call(ex) }
|
|
154
160
|
end
|
|
155
161
|
end
|
|
156
|
-
end
|
|
162
|
+
end
|
data/lib/skiplock/version.rb
CHANGED
data/lib/skiplock/worker.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Skiplock
|
|
|
5
5
|
|
|
6
6
|
def self.cleanup(hostname = nil)
|
|
7
7
|
delete_ids = []
|
|
8
|
-
self.where(hostname: hostname || Socket.gethostname).each do |worker|
|
|
8
|
+
self.where(namespace: Skiplock.namespace, hostname: hostname || Socket.gethostname).each do |worker|
|
|
9
9
|
sid = Process.getsid(worker.pid) rescue nil
|
|
10
10
|
delete_ids << worker.id if worker.sid != sid || worker.updated_at < 10.minutes.ago
|
|
11
11
|
end
|
|
@@ -13,115 +13,119 @@ module Skiplock
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def self.generate(capacity:, hostname:, master: true)
|
|
16
|
-
self.create!(pid: Process.pid, sid: Process.getsid(), master: master, hostname: hostname, capacity: capacity)
|
|
16
|
+
worker = self.create!(pid: Process.pid, sid: Process.getsid(), master: master, hostname: hostname, capacity: capacity, namespace: Skiplock.namespace)
|
|
17
17
|
rescue
|
|
18
|
-
self.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: hostname, capacity: capacity)
|
|
18
|
+
worker = self.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: hostname, capacity: capacity, namespace: Skiplock.namespace)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def shutdown
|
|
22
22
|
@running = false
|
|
23
23
|
@executor.shutdown
|
|
24
24
|
@executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
|
|
25
|
-
self.delete
|
|
26
25
|
Skiplock.logger.info "[Skiplock] Shutdown of #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2} (PID: #{self.pid}) was completed."
|
|
27
26
|
end
|
|
28
27
|
|
|
29
28
|
def start(worker_num: 0, **config)
|
|
30
29
|
@num = worker_num
|
|
31
30
|
@config = config
|
|
31
|
+
@pg_config = ActiveRecord::Base.connection.raw_connection.conninfo_hash.compact
|
|
32
|
+
@namespace_query = Skiplock.namespace.nil? ? "namespace IS NULL" : "namespace = '#{Skiplock.namespace}'"
|
|
32
33
|
@queues_order_query = @config[:queues].map { |q,v| "WHEN queue_name = '#{q}' THEN #{v}" }.join(' ') if @config[:queues].is_a?(Hash) && @config[:queues].count > 0
|
|
33
34
|
@running = true
|
|
34
|
-
@
|
|
35
|
+
@map = ::PG::TypeMapByOid.new
|
|
36
|
+
@map.add_coder(::PG::TextDecoder::Boolean.new(oid: 16, name: 'bool'))
|
|
37
|
+
@map.add_coder(::PG::TextDecoder::Integer.new(oid: 20, name: 'int8'))
|
|
38
|
+
@map.add_coder(::PG::TextDecoder::Integer.new(oid: 21, name: 'int2'))
|
|
39
|
+
@map.add_coder(::PG::TextDecoder::Integer.new(oid: 23, name: 'int4'))
|
|
40
|
+
@map.add_coder(::PG::TextDecoder::TimestampUtc.new(oid: 1114, name: 'timestamp'))
|
|
41
|
+
@map.add_coder(::PG::TextDecoder::String.new(oid: 2950, name: 'uuid'))
|
|
42
|
+
@map.add_coder(::PG::TextDecoder::JSON.new(oid: 3802, name: 'jsonb'))
|
|
43
|
+
@executor = Concurrent::ThreadPoolExecutor.new(min_threads: @config[:min_threads] + 1, max_threads: @config[:max_threads] + 1, max_queue: @config[:max_threads] + 1, idletime: 60, auto_terminate: false, fallback_policy: :abort)
|
|
35
44
|
@executor.post { run }
|
|
36
|
-
|
|
45
|
+
if @config[:standalone]
|
|
46
|
+
Process.setproctitle("skiplock: #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2} [#{Rails.application.class.name.deconstantize.downcase}:#{Rails.env}]")
|
|
47
|
+
ActiveRecord::Base.connection.throw_away!
|
|
48
|
+
end
|
|
37
49
|
end
|
|
38
50
|
|
|
39
51
|
private
|
|
40
52
|
|
|
53
|
+
def establish_connection
|
|
54
|
+
@connection = ::PG.connect(@pg_config)
|
|
55
|
+
@connection.type_map_for_results = @map
|
|
56
|
+
@connection.exec('LISTEN "skiplock::jobs"').clear
|
|
57
|
+
@connection.exec('LISTEN "skiplock::workers"').clear
|
|
58
|
+
end
|
|
59
|
+
|
|
41
60
|
def run
|
|
42
61
|
sleep 3
|
|
43
62
|
Skiplock.logger.info "[Skiplock] Starting in #{@config[:standalone] ? 'standalone' : 'async'} mode (PID: #{self.pid}) with #{@config[:max_threads]} max threads as #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2}..."
|
|
44
|
-
connection = nil
|
|
45
|
-
error = false
|
|
46
|
-
listen = false
|
|
47
63
|
next_schedule_at = Time.now.to_f
|
|
48
64
|
pg_exception_timestamp = nil
|
|
49
65
|
timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
50
66
|
while @running
|
|
51
67
|
Rails.application.reloader.wrap do
|
|
52
68
|
begin
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
Cron.setup
|
|
59
|
-
end
|
|
60
|
-
listen = true
|
|
61
|
-
end
|
|
62
|
-
if error
|
|
63
|
-
unless connection.active?
|
|
64
|
-
connection.reconnect!
|
|
65
|
-
sleep(0.5)
|
|
66
|
-
connection.exec_query('LISTEN "skiplock::jobs"')
|
|
67
|
-
Job.flush if self.master
|
|
68
|
-
pg_exception_timestamp = nil
|
|
69
|
-
next_schedule_at = Time.now.to_f
|
|
70
|
-
end
|
|
71
|
-
error = false
|
|
69
|
+
if @connection.nil? || @connection.status != ::PG::CONNECTION_OK
|
|
70
|
+
establish_connection
|
|
71
|
+
@executor.post { Rails.application.executor.wrap { Job.flush } } if self.master
|
|
72
|
+
pg_exception_timestamp = nil
|
|
73
|
+
next_schedule_at = Time.now.to_f
|
|
72
74
|
end
|
|
73
75
|
if Time.now.to_f >= next_schedule_at && @executor.remaining_capacity > 1 # reserves 1 slot in queue for Job.flush in case of pg_connection error
|
|
74
76
|
result = nil
|
|
75
|
-
connection.transaction do
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
@connection.transaction do |conn|
|
|
78
|
+
conn.exec("SELECT id, running, scheduled_at FROM skiplock.jobs WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL AND #{@namespace_query} 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") do |r|
|
|
79
|
+
result = r.first
|
|
80
|
+
conn.exec("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{result['id']}' RETURNING *") { |r| result = r.first } if result && result['scheduled_at'].to_f <= Time.now.to_f
|
|
81
|
+
end
|
|
78
82
|
end
|
|
79
83
|
if result && result['running']
|
|
80
|
-
@executor.post
|
|
81
|
-
Rails.application.executor.wrap { Job.instantiate(result).execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) }
|
|
82
|
-
end
|
|
84
|
+
@executor.post { Rails.application.executor.wrap { Job.instantiate(result).execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) } }
|
|
83
85
|
else
|
|
84
86
|
next_schedule_at = (result ? result['scheduled_at'].to_f : Float::INFINITY)
|
|
85
87
|
end
|
|
86
88
|
end
|
|
87
|
-
|
|
88
|
-
connection.
|
|
89
|
-
|
|
89
|
+
notifications = { 'skiplock::jobs' => [], 'skiplock::workers' => [] }
|
|
90
|
+
@connection.wait_for_notify(0.2) do |channel, pid, payload|
|
|
91
|
+
notifications[channel] << payload if payload
|
|
90
92
|
loop do
|
|
91
|
-
payload = connection.
|
|
93
|
+
payload = @connection.notifies
|
|
92
94
|
break unless @running && payload
|
|
93
|
-
|
|
95
|
+
notifications[payload[:relname]] << payload[:extra]
|
|
94
96
|
end
|
|
95
|
-
|
|
96
|
-
op, id, worker_id, job_class, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
|
|
97
|
-
next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0
|
|
97
|
+
notifications['skiplock::jobs'].each do |n|
|
|
98
|
+
op, id, worker_id, namespace, job_class, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
|
|
99
|
+
next if op == 'DELETE' || running == 'true' || namespace != Skiplock.namespace || expired_at.to_f > 0 || finished_at.to_f > 0
|
|
98
100
|
next_schedule_at = scheduled_at.to_f if scheduled_at.to_f < next_schedule_at
|
|
99
101
|
end
|
|
100
102
|
end
|
|
101
103
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
|
|
102
|
-
connection.
|
|
104
|
+
@connection.exec("UPDATE skiplock.workers SET updated_at = NOW() WHERE id = '#{self.id}'").clear
|
|
103
105
|
timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
104
106
|
end
|
|
105
107
|
rescue Exception => ex
|
|
106
|
-
Skiplock.logger.error(ex.to_s)
|
|
107
|
-
Skiplock.logger.error(ex.backtrace.join("\n"))
|
|
108
108
|
report_exception = true
|
|
109
109
|
# if error is with database connection then only report if it persists longer than 1 minute
|
|
110
|
-
if
|
|
110
|
+
if @connection.nil? || @connection.status != ::PG::CONNECTION_OK
|
|
111
111
|
report_exception = false if pg_exception_timestamp.nil? || Process.clock_gettime(Process::CLOCK_MONOTONIC) - pg_exception_timestamp <= 60
|
|
112
112
|
pg_exception_timestamp ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
113
|
end
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
if report_exception
|
|
115
|
+
Skiplock.logger.error(ex.to_s)
|
|
116
|
+
Skiplock.logger.error(ex.backtrace.join("\n"))
|
|
117
|
+
Skiplock.on_errors.each { |p| p.call(ex) }
|
|
118
|
+
end
|
|
116
119
|
wait(5)
|
|
117
120
|
end
|
|
118
121
|
sleep(0.3)
|
|
119
122
|
end
|
|
120
123
|
end
|
|
121
|
-
|
|
124
|
+
ensure
|
|
125
|
+
@connection.close if @connection && !@connection.finished?
|
|
122
126
|
end
|
|
123
127
|
|
|
124
|
-
def wait(timeout)
|
|
128
|
+
def wait(timeout = 1)
|
|
125
129
|
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
126
130
|
while @running
|
|
127
131
|
sleep(0.5)
|
|
@@ -129,4 +133,4 @@ module Skiplock
|
|
|
129
133
|
end
|
|
130
134
|
end
|
|
131
135
|
end
|
|
132
|
-
end
|
|
136
|
+
end
|
data/lib/skiplock.rb
CHANGED
|
@@ -11,7 +11,7 @@ require 'skiplock/worker'
|
|
|
11
11
|
require 'skiplock/version'
|
|
12
12
|
|
|
13
13
|
module Skiplock
|
|
14
|
-
DEFAULT_CONFIG = { '
|
|
14
|
+
DEFAULT_CONFIG = { 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 10, 'max_retries' => 20, 'logfile' => 'skiplock.log', 'loglevel' => 'info', 'namespace' => nil, 'notification' => 'custom', 'extensions' => false, 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
|
|
15
15
|
|
|
16
16
|
def self.logger=(l)
|
|
17
17
|
@logger = l
|
|
@@ -21,6 +21,14 @@ module Skiplock
|
|
|
21
21
|
@logger
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
def self.namespace=(n)
|
|
25
|
+
@namespace = n
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.namespace
|
|
29
|
+
@namespace || ''
|
|
30
|
+
end
|
|
31
|
+
|
|
24
32
|
def self.on_error(&block)
|
|
25
33
|
@on_errors ||= []
|
|
26
34
|
@on_errors << block
|
|
@@ -34,5 +42,4 @@ module Skiplock
|
|
|
34
42
|
def self.table_name_prefix
|
|
35
43
|
'skiplock.'
|
|
36
44
|
end
|
|
37
|
-
end
|
|
38
|
-
ActiveJob::Base.__send__(:include, Skiplock::Patch)
|
|
45
|
+
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
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tin Vo
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2022-01-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activejob
|