good_job 1.1.0 → 1.2.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/CHANGELOG.md +62 -5
- data/README.md +64 -14
- data/exe/good_job +1 -0
- data/lib/active_job/queue_adapters/good_job_adapter.rb +3 -29
- data/lib/good_job.rb +53 -1
- data/lib/good_job/adapter.rb +18 -18
- data/lib/good_job/cli.rb +13 -32
- data/lib/good_job/configuration.rb +55 -0
- data/lib/good_job/current_execution.rb +35 -0
- data/lib/good_job/job.rb +36 -5
- data/lib/good_job/log_subscriber.rb +84 -27
- data/lib/good_job/multi_scheduler.rb +34 -0
- data/lib/good_job/notifier.rb +116 -0
- data/lib/good_job/performer.rb +8 -1
- data/lib/good_job/railtie.rb +10 -0
- data/lib/good_job/scheduler.rb +112 -26
- data/lib/good_job/version.rb +1 -1
- metadata +49 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5fbc2c6b6b2d464188d5c89aa0d1a270c753bea661cda246ac41a4a86fa9cd77
|
4
|
+
data.tar.gz: 5fa873aa2732881e5b42cc63bbf2bdfe8fd823f224380fdc5c7e92e665b96584
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d7278790575965381edc0c5b9ee478e4156bbfe4dc97e9304dfed5a60f6d89043bf1ff567249680ff24aa85dd6a3b1539fda519e98a19762b02d657b1d943630
|
7
|
+
data.tar.gz: ce1bc4d1c2d9fa480f1a7544010f58ee7d1b656fe31d77869999e265cf3d0481a9da1e95ee855a351e36f11d992fb40ac85113cf0d0082c22e4988ca32ee149c
|
data/CHANGELOG.md
CHANGED
@@ -1,6 +1,67 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
-
## [v1.
|
3
|
+
## [v1.2.0](https://github.com/bensheldon/good_job/tree/v1.2.0) (2020-08-19)
|
4
|
+
|
5
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v1.1.4...v1.2.0)
|
6
|
+
|
7
|
+
**Merged pull requests:**
|
8
|
+
|
9
|
+
- Document GoodJob module [\#83](https://github.com/bensheldon/good_job/pull/83) ([bensheldon](https://github.com/bensheldon))
|
10
|
+
|
11
|
+
## [v1.1.4](https://github.com/bensheldon/good_job/tree/v1.1.4) (2020-08-19)
|
12
|
+
|
13
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v1.1.3...v1.1.4)
|
14
|
+
|
15
|
+
**Implemented enhancements:**
|
16
|
+
|
17
|
+
- Explicitly name threads for easier debugging [\#64](https://github.com/bensheldon/good_job/issues/64)
|
18
|
+
- Investigate Listen/Notify as alternative to polling [\#54](https://github.com/bensheldon/good_job/issues/54)
|
19
|
+
|
20
|
+
**Merged pull requests:**
|
21
|
+
|
22
|
+
- Add Postgres LISTEN/NOTIFY support [\#82](https://github.com/bensheldon/good_job/pull/82) ([bensheldon](https://github.com/bensheldon))
|
23
|
+
- Allow Schedulers to filter \#create\_thread to avoid flood of queries when running async with multiple schedulers [\#81](https://github.com/bensheldon/good_job/pull/81) ([bensheldon](https://github.com/bensheldon))
|
24
|
+
- Fully name scheduler threadpools and thread names; refactor CLI STDOUT [\#80](https://github.com/bensheldon/good_job/pull/80) ([bensheldon](https://github.com/bensheldon))
|
25
|
+
|
26
|
+
## [v1.1.3](https://github.com/bensheldon/good_job/tree/v1.1.3) (2020-08-14)
|
27
|
+
|
28
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v1.1.2...v1.1.3)
|
29
|
+
|
30
|
+
**Fixed bugs:**
|
31
|
+
|
32
|
+
- Job exceptions not properly attached to good\_jobs record [\#72](https://github.com/bensheldon/good_job/issues/72)
|
33
|
+
|
34
|
+
**Merged pull requests:**
|
35
|
+
|
36
|
+
- Capture errors via instrumentation from retry\_on and discard\_on [\#79](https://github.com/bensheldon/good_job/pull/79) ([bensheldon](https://github.com/bensheldon))
|
37
|
+
- Document GoodJob::Scheduler with Yard [\#78](https://github.com/bensheldon/good_job/pull/78) ([bensheldon](https://github.com/bensheldon))
|
38
|
+
|
39
|
+
## [v1.1.2](https://github.com/bensheldon/good_job/tree/v1.1.2) (2020-08-13)
|
40
|
+
|
41
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v1.1.1...v1.1.2)
|
42
|
+
|
43
|
+
**Implemented enhancements:**
|
44
|
+
|
45
|
+
- Allow the omission of queue names within a scheduler [\#73](https://github.com/bensheldon/good_job/issues/73)
|
46
|
+
|
47
|
+
**Merged pull requests:**
|
48
|
+
|
49
|
+
- Allow named queues to be excluded with a minus [\#77](https://github.com/bensheldon/good_job/pull/77) ([bensheldon](https://github.com/bensheldon))
|
50
|
+
|
51
|
+
## [v1.1.1](https://github.com/bensheldon/good_job/tree/v1.1.1) (2020-08-12)
|
52
|
+
|
53
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v1.1.0...v1.1.1)
|
54
|
+
|
55
|
+
**Implemented enhancements:**
|
56
|
+
|
57
|
+
- Allow multiple schedulers within the same process. e.g. `queues=mice:2,elephants:4` [\#45](https://github.com/bensheldon/good_job/issues/45)
|
58
|
+
|
59
|
+
**Merged pull requests:**
|
60
|
+
|
61
|
+
- Allow instantiation of multiple schedulers via --queues [\#76](https://github.com/bensheldon/good_job/pull/76) ([bensheldon](https://github.com/bensheldon))
|
62
|
+
- Extract options parsing to Configuration object [\#74](https://github.com/bensheldon/good_job/pull/74) ([bensheldon](https://github.com/bensheldon))
|
63
|
+
|
64
|
+
## [v1.1.0](https://github.com/bensheldon/good_job/tree/v1.1.0) (2020-08-10)
|
4
65
|
|
5
66
|
[Full Changelog](https://github.com/bensheldon/good_job/compare/v1.0.3...v1.1.0)
|
6
67
|
|
@@ -44,10 +105,6 @@
|
|
44
105
|
|
45
106
|
- Fix counting of available execution threads [\#58](https://github.com/bensheldon/good_job/pull/58) ([bensheldon](https://github.com/bensheldon))
|
46
107
|
|
47
|
-
**Closed issues:**
|
48
|
-
|
49
|
-
- repeating/recurring jobs [\#53](https://github.com/bensheldon/good_job/issues/53)
|
50
|
-
|
51
108
|
**Merged pull requests:**
|
52
109
|
|
53
110
|
- Add migration generator [\#56](https://github.com/bensheldon/good_job/pull/56) ([thedanbob](https://github.com/thedanbob))
|
data/README.md
CHANGED
@@ -6,7 +6,7 @@ GoodJob is a multithreaded, Postgres-based, ActiveJob backend for Ruby on Rails.
|
|
6
6
|
|
7
7
|
- **Designed for ActiveJob.** Complete support for [async, queues, delays, priorities, timeouts, and retries](https://edgeguides.rubyonrails.org/active_job_basics.html) with near-zero configuration.
|
8
8
|
- **Built for Rails.** Fully adopts Ruby on Rails [threading and code execution guidelines](https://guides.rubyonrails.org/threading_and_code_execution.html) with [Concurrent::Ruby](https://github.com/ruby-concurrency/concurrent-ruby).
|
9
|
-
- **Backed by Postgres.** Relies upon Postgres integrity
|
9
|
+
- **Backed by Postgres.** Relies upon Postgres integrity, session-level Advisory Locks to provide run-once safety and stay within the limits of `schema.rb`, and LISTEN/NOTIFY to reduce queuing latency.
|
10
10
|
- **For most workloads.** Targets full-stack teams, economy-minded solo developers, and applications that enqueue less than 1-million jobs/day.
|
11
11
|
|
12
12
|
For more of the story of GoodJob, read the [introductory blog post](https://island94.org/2020/07/introducing-goodjob-1-0).
|
@@ -72,17 +72,62 @@ $ bundle install
|
|
72
72
|
|
73
73
|
Configuration options available with `help`:
|
74
74
|
|
75
|
-
|
76
|
-
|
75
|
+
```bash
|
76
|
+
$ bundle exec good_job help start
|
77
77
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
78
|
+
Usage:
|
79
|
+
good_job start
|
80
|
+
|
81
|
+
Options:
|
82
|
+
[--max-threads=N] # Maximum number of threads to use for working jobs (default: ActiveRecord::Base.connection_pool.size)
|
83
|
+
[--queues=queue1,queue2(;queue3,queue4:5;-queue1,queue2)] # Queues to work from. Separate multiple queues with commas; exclude queues with a leading minus; separate isolated execution pools with semicolons and threads with colons (default: *)
|
84
|
+
[--poll-interval=N] # Interval between polls for available jobs in seconds (default: 1)
|
85
|
+
|
86
|
+
Start job worker
|
87
|
+
```
|
88
|
+
|
89
|
+
1. Optimize execution to reduce congestion and execution latency.
|
90
|
+
|
91
|
+
By default, GoodJob creates a single thread execution pool that will execute jobs from any queue. Depending on your application's workload, job types, and service level objectives, you may wish to optimize execution resources; for example, providing dedicated execution resources for transactional emails so they are not delayed by long-running batch jobs. Some options:
|
92
|
+
|
93
|
+
- Multiple execution pools within a single process:
|
94
|
+
|
95
|
+
```bash
|
96
|
+
$ bundle exec good_job --queues=transactional_messages:2;batch_processing:1;-transactional_messages,batch_processing:2;* --max-threads=5
|
97
|
+
```
|
98
|
+
|
99
|
+
This configuration will result in a single process with 4 isolated thread execution pools. Isolated execution pools are separated with a semicolon (`;`) and queue names and thread counts with a colon (`:`)
|
100
|
+
|
101
|
+
- `transactional_messages:2`: execute jobs enqueued on `transactional_messages` with up to 2 threads.
|
102
|
+
- `batch_processing:1` execute jobs enqueued on `batch_processing` with a single thread.
|
103
|
+
- `-transactional_messages,batch_processing`: execute jobs enqueued on _any_ queue _excluding_ `transactional_messages` or `batch_processing` with up to 2 threads.
|
104
|
+
- `*`: execute jobs on any queue on up to 5 threads, as configured by `--max-threads=5`
|
105
|
+
|
106
|
+
For moderate workloads, multiple isolated thread execution pools offers a good balance between congestion management and economy.
|
107
|
+
|
108
|
+
Configuration can be injected by environment variables too:
|
109
|
+
|
110
|
+
```bash
|
111
|
+
$ GOOD_JOB_QUEUES="transactional_messages:2;batch_processing:1;-transactional_messages,batch_processing:2;*" GOOD_JOB_MAX_THREADS=5 bundle exec good_job
|
112
|
+
```
|
113
|
+
|
114
|
+
- Multiple processes; for example, on Heroku:
|
115
|
+
|
116
|
+
```procfile
|
117
|
+
# Procfile
|
118
|
+
|
119
|
+
# Separate dyno types
|
120
|
+
worker: bundle exec good_job --max-threads=5
|
121
|
+
transactional_worker: bundle exec good_job --queues=transactional_messages --max-threads=2
|
122
|
+
batch_worker: bundle exec good_job --queues=batch_processing --max-threads=1
|
123
|
+
|
124
|
+
# Combined multi-process dyno
|
125
|
+
combined_worker: bundle exec good_job --max-threads=5 & bundle exec good_job --queues=transactional_messages --max-threads=2 & bundle exec good_job --queues=batch_processing --max-threads=1 & wait -n
|
126
|
+
```
|
127
|
+
|
128
|
+
Running multiple processes can optimize for CPU performance at the expense of greater memory and system resource usage.
|
129
|
+
|
130
|
+
_Keep in mind, queue operations and management is an advanced discipline. This stuff is complex, especially for heavy workloads and unique processing requirements. Good job 👍_
|
86
131
|
|
87
132
|
### Error handling, retries, and reliability
|
88
133
|
|
@@ -239,15 +284,20 @@ Depending on your application configuration, you may need to take additional ste
|
|
239
284
|
# config/puma.rb
|
240
285
|
|
241
286
|
before_fork do
|
242
|
-
GoodJob
|
287
|
+
GoodJob.shutdown
|
243
288
|
end
|
244
289
|
|
245
290
|
on_worker_boot do
|
246
|
-
GoodJob
|
291
|
+
GoodJob.restart
|
247
292
|
end
|
248
293
|
|
249
294
|
on_worker_shutdown do
|
250
|
-
GoodJob
|
295
|
+
GoodJob.shutdown
|
296
|
+
end
|
297
|
+
|
298
|
+
MAIN_PID = Process.pid
|
299
|
+
at_exit do
|
300
|
+
GoodJob.shutdown if Process.pid == MAIN_PID
|
251
301
|
end
|
252
302
|
```
|
253
303
|
|
data/exe/good_job
CHANGED
@@ -1,35 +1,9 @@
|
|
1
1
|
module ActiveJob
|
2
2
|
module QueueAdapters
|
3
3
|
class GoodJobAdapter < GoodJob::Adapter
|
4
|
-
def initialize(execution_mode: nil, max_threads: nil, poll_interval: nil, scheduler: nil)
|
5
|
-
|
6
|
-
|
7
|
-
elsif ENV['GOOD_JOB_EXECUTION_MODE'].present?
|
8
|
-
ENV['GOOD_JOB_EXECUTION_MODE'].to_sym
|
9
|
-
elsif Rails.env.development?
|
10
|
-
:inline
|
11
|
-
elsif Rails.env.test?
|
12
|
-
:inline
|
13
|
-
else
|
14
|
-
:external
|
15
|
-
end
|
16
|
-
|
17
|
-
if execution_mode == :async && scheduler.blank?
|
18
|
-
max_threads = (
|
19
|
-
max_threads.presence ||
|
20
|
-
ENV['GOOD_JOB_MAX_THREADS'] ||
|
21
|
-
ENV['RAILS_MAX_THREADS'] ||
|
22
|
-
ActiveRecord::Base.connection_pool.size
|
23
|
-
).to_i
|
24
|
-
|
25
|
-
poll_interval = (
|
26
|
-
poll_interval.presence ||
|
27
|
-
ENV['GOOD_JOB_POLL_INTERVAL'] ||
|
28
|
-
1
|
29
|
-
).to_i
|
30
|
-
end
|
31
|
-
|
32
|
-
super(execution_mode: execution_mode, max_threads: max_threads, poll_interval: poll_interval, scheduler: scheduler)
|
4
|
+
def initialize(execution_mode: nil, max_threads: nil, poll_interval: nil, scheduler: nil, inline: false)
|
5
|
+
configuration = GoodJob::Configuration.new({ execution_mode: execution_mode }, env: ENV)
|
6
|
+
super(execution_mode: configuration.rails_execution_mode, max_threads: max_threads, poll_interval: poll_interval, scheduler: scheduler, inline: inline)
|
33
7
|
end
|
34
8
|
end
|
35
9
|
end
|
data/lib/good_job.rb
CHANGED
@@ -1,21 +1,73 @@
|
|
1
1
|
require "rails"
|
2
2
|
require 'good_job/railtie'
|
3
3
|
|
4
|
+
require 'good_job/configuration'
|
4
5
|
require 'good_job/log_subscriber'
|
5
6
|
require 'good_job/lockable'
|
6
7
|
require 'good_job/job'
|
7
8
|
require 'good_job/scheduler'
|
9
|
+
require 'good_job/multi_scheduler'
|
8
10
|
require 'good_job/adapter'
|
9
11
|
require 'good_job/pg_locks'
|
10
12
|
require 'good_job/performer'
|
13
|
+
require 'good_job/current_execution'
|
14
|
+
require 'good_job/notifier'
|
11
15
|
|
12
16
|
require 'active_job/queue_adapters/good_job_adapter'
|
13
17
|
|
18
|
+
# GoodJob is a multithreaded, Postgres-based, ActiveJob backend for Ruby on Rails.
|
19
|
+
#
|
20
|
+
# +GoodJob+ is the top-level namespace and exposes configuration attributes.
|
14
21
|
module GoodJob
|
15
|
-
|
22
|
+
# @!attribute [rw] logger
|
23
|
+
# @!scope class
|
24
|
+
# The logger used by GoodJob
|
25
|
+
# @return [Logger]
|
26
|
+
mattr_accessor :logger, default: ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
|
27
|
+
|
28
|
+
# @!attribute [rw] preserve_job_records
|
29
|
+
# @!scope class
|
30
|
+
# Whether to preserve job records in the database after they have finished for inspection
|
31
|
+
# @return [Boolean]
|
16
32
|
mattr_accessor :preserve_job_records, default: false
|
33
|
+
|
34
|
+
# @!attribute [rw] reperform_jobs_on_standard_error
|
35
|
+
# @!scope class
|
36
|
+
# Whether to re-perform a job when a type of +StandardError+ is raised and bubbles up to the GoodJob backend
|
37
|
+
# @return [Boolean]
|
17
38
|
mattr_accessor :reperform_jobs_on_standard_error, default: true
|
39
|
+
|
40
|
+
# @!attribute [rw] on_thread_error
|
41
|
+
# @!scope class
|
42
|
+
# Called when a thread raises an error
|
43
|
+
# @example Send errors to Sentry
|
44
|
+
# # config/initializers/good_job.rb
|
45
|
+
#
|
46
|
+
# # With Sentry (or Bugsnag, Airbrake, Honeybadger, etc.)
|
47
|
+
# GoodJob.on_thread_error = -> (exception) { Raven.capture_exception(exception) }
|
48
|
+
# @return [#call, nil]
|
18
49
|
mattr_accessor :on_thread_error, default: nil
|
19
50
|
|
51
|
+
# Shuts down all execution pools
|
52
|
+
# @param wait [Boolean] whether to wait for shutdown
|
53
|
+
# @return [void]
|
54
|
+
def self.shutdown(wait: true)
|
55
|
+
Notifier.instances.each { |adapter| adapter.shutdown(wait: wait) }
|
56
|
+
Scheduler.instances.each { |scheduler| scheduler.shutdown(wait: wait) }
|
57
|
+
end
|
58
|
+
|
59
|
+
# Tests if execution pools are shut down
|
60
|
+
# @return [Boolean] whether execution pools are shut down
|
61
|
+
def self.shutdown?
|
62
|
+
Notifier.instances.all?(&:shutdown?) && Scheduler.instances.all?(&:shutdown?)
|
63
|
+
end
|
64
|
+
|
65
|
+
# Restarts all execution pools
|
66
|
+
# @return [void]
|
67
|
+
def self.restart
|
68
|
+
Notifier.instances.each(&:restart)
|
69
|
+
Scheduler.instances.each(&:restart)
|
70
|
+
end
|
71
|
+
|
20
72
|
ActiveSupport.run_load_hooks(:good_job, self)
|
21
73
|
end
|
data/lib/good_job/adapter.rb
CHANGED
@@ -2,28 +2,26 @@ module GoodJob
|
|
2
2
|
class Adapter
|
3
3
|
EXECUTION_MODES = [:async, :external, :inline].freeze
|
4
4
|
|
5
|
-
def initialize(execution_mode: nil, max_threads: nil, poll_interval: nil, scheduler: nil, inline: false)
|
6
|
-
if inline
|
5
|
+
def initialize(execution_mode: nil, queues: nil, max_threads: nil, poll_interval: nil, scheduler: nil, notifier: nil, inline: false)
|
6
|
+
if inline && execution_mode.nil?
|
7
7
|
ActiveSupport::Deprecation.warn('GoodJob::Adapter#new(inline: true) is deprecated; use GoodJob::Adapter.new(execution_mode: :inline) instead')
|
8
|
-
|
9
|
-
elsif execution_mode
|
10
|
-
raise ArgumentError, "execution_mode: must be one of #{EXECUTION_MODES.join(', ')}." unless EXECUTION_MODES.include?(execution_mode)
|
11
|
-
|
12
|
-
@execution_mode = execution_mode
|
13
|
-
else
|
14
|
-
@execution_mode = :external
|
8
|
+
execution_mode = :inline
|
15
9
|
end
|
16
10
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
11
|
+
configuration = GoodJob::Configuration.new(
|
12
|
+
execution_mode: execution_mode,
|
13
|
+
queues: queues,
|
14
|
+
max_threads: max_threads,
|
15
|
+
poll_interval: poll_interval
|
16
|
+
)
|
21
17
|
|
22
|
-
|
23
|
-
|
18
|
+
@execution_mode = configuration.execution_mode
|
19
|
+
raise ArgumentError, "execution_mode: must be one of #{EXECUTION_MODES.join(', ')}." unless EXECUTION_MODES.include?(@execution_mode)
|
24
20
|
|
25
|
-
|
26
|
-
@
|
21
|
+
if @execution_mode == :async # rubocop:disable Style/GuardClause
|
22
|
+
@notifier = notifier || GoodJob::Notifier.new
|
23
|
+
@scheduler = scheduler || GoodJob::Scheduler.from_configuration(configuration)
|
24
|
+
@notifier.recipients << [@scheduler, :create_thread]
|
27
25
|
end
|
28
26
|
end
|
29
27
|
|
@@ -46,12 +44,14 @@ module GoodJob
|
|
46
44
|
end
|
47
45
|
end
|
48
46
|
|
49
|
-
@scheduler.create_thread
|
47
|
+
executed_locally = execute_async? && @scheduler.create_thread(queue_name: good_job.queue_name)
|
48
|
+
Notifier.notify(queue_name: good_job.queue_name) unless executed_locally
|
50
49
|
|
51
50
|
good_job
|
52
51
|
end
|
53
52
|
|
54
53
|
def shutdown(wait: true)
|
54
|
+
@notifier&.shutdown(wait: wait)
|
55
55
|
@scheduler&.shutdown(wait: wait)
|
56
56
|
end
|
57
57
|
|
data/lib/good_job/cli.rb
CHANGED
@@ -10,43 +10,18 @@ module GoodJob
|
|
10
10
|
desc: "Maximum number of threads to use for working jobs (default: ActiveRecord::Base.connection_pool.size)"
|
11
11
|
method_option :queues,
|
12
12
|
type: :string,
|
13
|
-
banner: "queue1,queue2",
|
14
|
-
desc: "Queues to work from. Separate multiple queues with commas (default: *)"
|
13
|
+
banner: "queue1,queue2(;queue3,queue4:5;-queue1,queue2)",
|
14
|
+
desc: "Queues to work from. Separate multiple queues with commas; exclude queues with a leading minus; separate isolated execution pools with semicolons and threads with colons (default: *)"
|
15
15
|
method_option :poll_interval,
|
16
16
|
type: :numeric,
|
17
17
|
desc: "Interval between polls for available jobs in seconds (default: 1)"
|
18
18
|
def start
|
19
19
|
set_up_application!
|
20
20
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
ActiveRecord::Base.connection_pool.size
|
26
|
-
).to_i
|
27
|
-
|
28
|
-
queue_string = (
|
29
|
-
options[:queues] ||
|
30
|
-
ENV['GOOD_JOB_QUEUES'] ||
|
31
|
-
'*'
|
32
|
-
)
|
33
|
-
|
34
|
-
poll_interval = (
|
35
|
-
options[:poll_interval] ||
|
36
|
-
ENV['GOOD_JOB_POLL_INTERVAL']
|
37
|
-
).to_i
|
38
|
-
|
39
|
-
job_query = GoodJob::Job.queue_string(queue_string)
|
40
|
-
job_performer = GoodJob::Performer.new(job_query, :perform_with_advisory_lock, name: queue_string)
|
41
|
-
|
42
|
-
timer_options = {}
|
43
|
-
timer_options[:execution_interval] = poll_interval if poll_interval.positive?
|
44
|
-
|
45
|
-
pool_options = {
|
46
|
-
max_threads: max_threads,
|
47
|
-
}
|
48
|
-
|
49
|
-
scheduler = GoodJob::Scheduler.new(job_performer, timer_options: timer_options, pool_options: pool_options)
|
21
|
+
notifier = GoodJob::Notifier.new
|
22
|
+
configuration = GoodJob::Configuration.new(options)
|
23
|
+
scheduler = GoodJob::Scheduler.from_configuration(configuration)
|
24
|
+
notifier.recipients << [scheduler, :create_thread]
|
50
25
|
|
51
26
|
@stop_good_job_executable = false
|
52
27
|
%w[INT TERM].each do |signal|
|
@@ -55,9 +30,10 @@ module GoodJob
|
|
55
30
|
|
56
31
|
Kernel.loop do
|
57
32
|
sleep 0.1
|
58
|
-
break if @stop_good_job_executable || scheduler.shutdown?
|
33
|
+
break if @stop_good_job_executable || scheduler.shutdown? || notifier.shutdown?
|
59
34
|
end
|
60
35
|
|
36
|
+
notifier.shutdown
|
61
37
|
scheduler.shutdown
|
62
38
|
end
|
63
39
|
|
@@ -68,6 +44,7 @@ module GoodJob
|
|
68
44
|
type: :numeric,
|
69
45
|
default: 24 * 60 * 60,
|
70
46
|
desc: "Delete records finished more than this many seconds ago"
|
47
|
+
|
71
48
|
def cleanup_preserved_jobs
|
72
49
|
set_up_application!
|
73
50
|
|
@@ -82,6 +59,10 @@ module GoodJob
|
|
82
59
|
no_commands do
|
83
60
|
def set_up_application!
|
84
61
|
require RAILS_ENVIRONMENT_RB
|
62
|
+
return unless defined?(GOOD_JOB_LOG_TO_STDOUT) && GOOD_JOB_LOG_TO_STDOUT && !ActiveSupport::Logger.logger_outputs_to?(GoodJob.logger, STDOUT)
|
63
|
+
|
64
|
+
GoodJob::LogSubscriber.loggers << ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
|
65
|
+
GoodJob::LogSubscriber.reset_logger
|
85
66
|
end
|
86
67
|
end
|
87
68
|
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module GoodJob
|
2
|
+
class Configuration
|
3
|
+
attr_reader :options, :env
|
4
|
+
|
5
|
+
def initialize(options, env: ENV)
|
6
|
+
@options = options
|
7
|
+
@env = env
|
8
|
+
end
|
9
|
+
|
10
|
+
def execution_mode(default: :external)
|
11
|
+
if options[:execution_mode]
|
12
|
+
options[:execution_mode]
|
13
|
+
elsif env['GOOD_JOB_EXECUTION_MODE'].present?
|
14
|
+
env['GOOD_JOB_EXECUTION_MODE'].to_sym
|
15
|
+
else
|
16
|
+
default
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def rails_execution_mode
|
21
|
+
if execution_mode(default: nil)
|
22
|
+
execution_mode
|
23
|
+
elsif Rails.env.development?
|
24
|
+
:inline
|
25
|
+
elsif Rails.env.test?
|
26
|
+
:inline
|
27
|
+
else
|
28
|
+
:external
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def max_threads
|
33
|
+
(
|
34
|
+
options[:max_threads] ||
|
35
|
+
env['GOOD_JOB_MAX_THREADS'] ||
|
36
|
+
env['RAILS_MAX_THREADS'] ||
|
37
|
+
ActiveRecord::Base.connection_pool.size
|
38
|
+
).to_i
|
39
|
+
end
|
40
|
+
|
41
|
+
def queue_string
|
42
|
+
options[:queues] ||
|
43
|
+
env['GOOD_JOB_QUEUES'] ||
|
44
|
+
'*'
|
45
|
+
end
|
46
|
+
|
47
|
+
def poll_interval
|
48
|
+
(
|
49
|
+
options[:poll_interval] ||
|
50
|
+
env['GOOD_JOB_POLL_INTERVAL'] ||
|
51
|
+
1
|
52
|
+
).to_i
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module GoodJob
|
2
|
+
# Thread-local attributes for passing values from Instrumentation.
|
3
|
+
# (Cannot use ActiveSupport::CurrentAttributes because ActiveJob resets it)
|
4
|
+
|
5
|
+
module CurrentExecution
|
6
|
+
# @!attribute [rw] error_on_retry
|
7
|
+
# @!scope class
|
8
|
+
# Error captured by retry_on
|
9
|
+
# @return [Exception, nil]
|
10
|
+
thread_mattr_accessor :error_on_retry
|
11
|
+
|
12
|
+
# @!attribute [rw] error_on_discard
|
13
|
+
# @!scope class
|
14
|
+
# Error captured by discard_on
|
15
|
+
# @return [Exception, nil]
|
16
|
+
thread_mattr_accessor :error_on_discard
|
17
|
+
|
18
|
+
# Resets attributes
|
19
|
+
# @return [void]
|
20
|
+
def self.reset
|
21
|
+
self.error_on_retry = nil
|
22
|
+
self.error_on_discard = nil
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Integer] Current process ID
|
26
|
+
def self.process_id
|
27
|
+
Process.pid
|
28
|
+
end
|
29
|
+
|
30
|
+
# @return [String] Current thread name
|
31
|
+
def self.thread_name
|
32
|
+
(Thread.current.name || Thread.current.object_id).to_s
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/good_job/job.rb
CHANGED
@@ -9,6 +9,25 @@ module GoodJob
|
|
9
9
|
|
10
10
|
self.table_name = 'good_jobs'.freeze
|
11
11
|
|
12
|
+
def self.queue_parser(string)
|
13
|
+
string = string.presence || '*'
|
14
|
+
|
15
|
+
if string.first == '-'
|
16
|
+
exclude_queues = true
|
17
|
+
string = string[1..-1]
|
18
|
+
end
|
19
|
+
|
20
|
+
queues = string.split(',').map(&:strip)
|
21
|
+
|
22
|
+
if queues.include?('*')
|
23
|
+
{ all: true }
|
24
|
+
elsif exclude_queues
|
25
|
+
{ exclude: queues }
|
26
|
+
else
|
27
|
+
{ include: queues }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
12
31
|
scope :unfinished, (lambda do
|
13
32
|
if column_names.include?('finished_at')
|
14
33
|
where(finished_at: nil)
|
@@ -21,8 +40,15 @@ module GoodJob
|
|
21
40
|
scope :priority_ordered, -> { order('priority DESC NULLS LAST') }
|
22
41
|
scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(timestamp)) : where.not(finished_at: nil) }
|
23
42
|
scope :queue_string, (lambda do |string|
|
24
|
-
|
25
|
-
|
43
|
+
parsed = queue_parser(string)
|
44
|
+
|
45
|
+
if parsed[:all]
|
46
|
+
all
|
47
|
+
elsif parsed[:exclude]
|
48
|
+
where.not(queue_name: parsed[:exclude]).or where(queue_name: nil)
|
49
|
+
elsif parsed[:include]
|
50
|
+
where(queue_name: parsed[:include])
|
51
|
+
end
|
26
52
|
end)
|
27
53
|
|
28
54
|
def self.perform_with_advisory_lock
|
@@ -47,7 +73,7 @@ module GoodJob
|
|
47
73
|
queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
|
48
74
|
priority: active_job.priority || DEFAULT_PRIORITY,
|
49
75
|
serialized_params: active_job.serialize,
|
50
|
-
scheduled_at: scheduled_at
|
76
|
+
scheduled_at: scheduled_at,
|
51
77
|
create_with_advisory_lock: create_with_advisory_lock
|
52
78
|
)
|
53
79
|
|
@@ -63,11 +89,11 @@ module GoodJob
|
|
63
89
|
def perform(destroy_after: !GoodJob.preserve_job_records, reperform_on_standard_error: GoodJob.reperform_jobs_on_standard_error)
|
64
90
|
raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
|
65
91
|
|
92
|
+
GoodJob::CurrentExecution.reset
|
66
93
|
result = nil
|
67
94
|
rescued_error = nil
|
68
95
|
error = nil
|
69
96
|
|
70
|
-
ActiveSupport::Notifications.instrument("before_perform_job.good_job", { good_job: self })
|
71
97
|
self.performed_at = Time.current
|
72
98
|
save! unless destroy_after
|
73
99
|
|
@@ -76,18 +102,23 @@ module GoodJob
|
|
76
102
|
)
|
77
103
|
|
78
104
|
begin
|
79
|
-
ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self }) do
|
105
|
+
ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, process_id: GoodJob::CurrentExecution.process_id, thread_name: GoodJob::CurrentExecution.thread_name }) do
|
80
106
|
result = ActiveJob::Base.execute(params)
|
81
107
|
end
|
82
108
|
rescue StandardError => e
|
83
109
|
rescued_error = e
|
84
110
|
end
|
85
111
|
|
112
|
+
retry_or_discard_error = GoodJob::CurrentExecution.error_on_retry ||
|
113
|
+
GoodJob::CurrentExecution.error_on_discard
|
114
|
+
|
86
115
|
if rescued_error
|
87
116
|
error = rescued_error
|
88
117
|
elsif result.is_a?(Exception)
|
89
118
|
error = result
|
90
119
|
result = nil
|
120
|
+
elsif retry_or_discard_error
|
121
|
+
error = retry_or_discard_error
|
91
122
|
end
|
92
123
|
|
93
124
|
error_message = "#{error.class}: #{error.message}" if error
|
@@ -32,7 +32,7 @@ module GoodJob
|
|
32
32
|
performer_name = event.payload[:performer_name]
|
33
33
|
process_id = event.payload[:process_id]
|
34
34
|
|
35
|
-
|
35
|
+
info(tags: [process_id]) do
|
36
36
|
"GoodJob started scheduler with queues=#{performer_name} max_threads=#{max_threads} poll_interval=#{poll_interval}."
|
37
37
|
end
|
38
38
|
end
|
@@ -40,7 +40,7 @@ module GoodJob
|
|
40
40
|
def scheduler_shutdown_start(event)
|
41
41
|
process_id = event.payload[:process_id]
|
42
42
|
|
43
|
-
|
43
|
+
info(tags: [process_id]) do
|
44
44
|
"GoodJob shutting down scheduler..."
|
45
45
|
end
|
46
46
|
end
|
@@ -48,7 +48,7 @@ module GoodJob
|
|
48
48
|
def scheduler_shutdown(event)
|
49
49
|
process_id = event.payload[:process_id]
|
50
50
|
|
51
|
-
|
51
|
+
info(tags: [process_id]) do
|
52
52
|
"GoodJob scheduler is shut down."
|
53
53
|
end
|
54
54
|
end
|
@@ -56,24 +56,100 @@ module GoodJob
|
|
56
56
|
def scheduler_restart_pools(event)
|
57
57
|
process_id = event.payload[:process_id]
|
58
58
|
|
59
|
-
|
59
|
+
info(tags: [process_id]) do
|
60
60
|
"GoodJob scheduler has restarted."
|
61
61
|
end
|
62
62
|
end
|
63
63
|
|
64
|
+
def perform_job(event)
|
65
|
+
good_job = event.payload[:good_job]
|
66
|
+
process_id = event.payload[:process_id]
|
67
|
+
thread_name = event.payload[:thread_name]
|
68
|
+
|
69
|
+
info(tags: [process_id, thread_name]) do
|
70
|
+
"Executed GoodJob #{good_job.id}"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def notifier_listen(_event)
|
75
|
+
info do
|
76
|
+
"Notifier subscribed with LISTEN"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def notifier_notified(event)
|
81
|
+
payload = event.payload[:payload]
|
82
|
+
|
83
|
+
debug do
|
84
|
+
"Notifier received payload: #{payload}"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def notifier_notify_error(event)
|
89
|
+
error = event.payload[:error]
|
90
|
+
|
91
|
+
error do
|
92
|
+
"Notifier errored: #{error}"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def notifier_unlisten(_event)
|
97
|
+
info do
|
98
|
+
"Notifier unsubscribed with UNLISTEN"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
64
102
|
def cleanup_preserved_jobs(event)
|
65
103
|
timestamp = event.payload[:timestamp]
|
66
104
|
deleted_records_count = event.payload[:deleted_records_count]
|
67
105
|
|
68
|
-
|
106
|
+
info do
|
69
107
|
"GoodJob deleted #{deleted_records_count} preserved #{'job'.pluralize(deleted_records_count)} finished before #{timestamp}."
|
70
108
|
end
|
71
109
|
end
|
72
110
|
|
73
|
-
|
111
|
+
class << self
|
112
|
+
def loggers
|
113
|
+
@_loggers ||= [GoodJob.logger]
|
114
|
+
end
|
115
|
+
|
116
|
+
def logger
|
117
|
+
@_logger ||= begin
|
118
|
+
logger = Logger.new(StringIO.new)
|
119
|
+
loggers.each do |each_logger|
|
120
|
+
logger.extend(ActiveSupport::Logger.broadcast(each_logger))
|
121
|
+
end
|
122
|
+
logger
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def reset_logger
|
127
|
+
@_logger = nil
|
128
|
+
end
|
129
|
+
end
|
74
130
|
|
75
131
|
def logger
|
76
|
-
GoodJob.logger
|
132
|
+
GoodJob::LogSubscriber.logger
|
133
|
+
end
|
134
|
+
|
135
|
+
private
|
136
|
+
|
137
|
+
def tag_logger(*tags, &block)
|
138
|
+
tags = tags.dup.unshift("GoodJob").compact
|
139
|
+
|
140
|
+
self.class.loggers.inject(block) do |inner, each_logger|
|
141
|
+
if each_logger.respond_to?(:tagged)
|
142
|
+
tags_for_logger = if each_logger.formatter.current_tags.include?("ActiveJob")
|
143
|
+
["ActiveJob"] + tags
|
144
|
+
else
|
145
|
+
tags
|
146
|
+
end
|
147
|
+
|
148
|
+
proc { each_logger.tagged(*tags_for_logger, &inner) }
|
149
|
+
else
|
150
|
+
inner
|
151
|
+
end
|
152
|
+
end.call
|
77
153
|
end
|
78
154
|
|
79
155
|
%w(info debug warn error fatal unknown).each do |level|
|
@@ -81,30 +157,11 @@ module GoodJob
|
|
81
157
|
def #{level}(progname = nil, tags: [], &block)
|
82
158
|
return unless logger
|
83
159
|
|
84
|
-
|
85
|
-
tags.unshift "GoodJob" unless logger.formatter.current_tags.include?("GoodJob")
|
86
|
-
logger.tagged(*tags.compact) do
|
87
|
-
logger.#{level}(progname, &block)
|
88
|
-
end
|
89
|
-
else
|
160
|
+
tag_logger(*tags) do
|
90
161
|
logger.#{level}(progname, &block)
|
91
162
|
end
|
92
163
|
end
|
93
164
|
METHOD
|
94
165
|
end
|
95
|
-
|
96
|
-
def info_and_stdout(progname = nil, tags: [], &block)
|
97
|
-
unless ActiveSupport::Logger.logger_outputs_to?(logger, STDOUT)
|
98
|
-
tags_string = (['GoodJob'] + tags).map { |t| "[#{t}]" }.join(' ')
|
99
|
-
stdout_message = "#{tags_string}#{yield}"
|
100
|
-
$stdout.puts stdout_message
|
101
|
-
end
|
102
|
-
|
103
|
-
info(progname, tags: [], &block)
|
104
|
-
end
|
105
|
-
|
106
|
-
def thread_name
|
107
|
-
Thread.current.name || Thread.current.object_id
|
108
|
-
end
|
109
166
|
end
|
110
167
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module GoodJob
|
2
|
+
class MultiScheduler
|
3
|
+
attr_reader :schedulers
|
4
|
+
|
5
|
+
def initialize(schedulers)
|
6
|
+
@schedulers = schedulers
|
7
|
+
end
|
8
|
+
|
9
|
+
def shutdown(wait: true)
|
10
|
+
schedulers.each { |s| s.shutdown(wait: wait) }
|
11
|
+
end
|
12
|
+
|
13
|
+
def shutdown?
|
14
|
+
schedulers.all?(&:shutdown?)
|
15
|
+
end
|
16
|
+
|
17
|
+
def restart(wait: true)
|
18
|
+
schedulers.each { |s| s.restart(wait: wait) }
|
19
|
+
end
|
20
|
+
|
21
|
+
def create_thread(state = nil)
|
22
|
+
results = []
|
23
|
+
any_true = schedulers.any? do |scheduler|
|
24
|
+
scheduler.create_thread(state).tap { |result| results << result }
|
25
|
+
end
|
26
|
+
|
27
|
+
if any_true
|
28
|
+
true
|
29
|
+
else
|
30
|
+
results.any? { |result| result == false } ? false : nil
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
require 'concurrent/atomic/atomic_boolean'
|
2
|
+
|
3
|
+
module GoodJob # :nodoc:
|
4
|
+
#
|
5
|
+
# Wrapper for Postgres LISTEN/NOTIFY
|
6
|
+
#
|
7
|
+
class Notifier
|
8
|
+
CHANNEL = 'good_job'.freeze
|
9
|
+
POOL_OPTIONS = {
|
10
|
+
min_threads: 0,
|
11
|
+
max_threads: 1,
|
12
|
+
auto_terminate: true,
|
13
|
+
idletime: 60,
|
14
|
+
max_queue: 1,
|
15
|
+
fallback_policy: :discard,
|
16
|
+
}.freeze
|
17
|
+
WAIT_INTERVAL = 1
|
18
|
+
|
19
|
+
# @!attribute [r] instances
|
20
|
+
# @!scope class
|
21
|
+
# @return [array<GoodJob:Adapter>] the instances of +GoodJob::Notifier+
|
22
|
+
cattr_reader :instances, default: [], instance_reader: false
|
23
|
+
|
24
|
+
def self.notify(message)
|
25
|
+
connection = ActiveRecord::Base.connection
|
26
|
+
connection.exec_query <<~SQL
|
27
|
+
NOTIFY #{CHANNEL}, #{connection.quote(message.to_json)}
|
28
|
+
SQL
|
29
|
+
end
|
30
|
+
|
31
|
+
attr_reader :recipients
|
32
|
+
|
33
|
+
def initialize(*recipients)
|
34
|
+
@recipients = Concurrent::Array.new(recipients)
|
35
|
+
@listening = Concurrent::AtomicBoolean.new(false)
|
36
|
+
|
37
|
+
self.class.instances << self
|
38
|
+
|
39
|
+
create_pool
|
40
|
+
listen
|
41
|
+
end
|
42
|
+
|
43
|
+
def listening?
|
44
|
+
@listening.true?
|
45
|
+
end
|
46
|
+
|
47
|
+
def restart(wait: true)
|
48
|
+
shutdown(wait: wait)
|
49
|
+
create_pool
|
50
|
+
listen
|
51
|
+
end
|
52
|
+
|
53
|
+
def shutdown(wait: true)
|
54
|
+
return unless @pool.running?
|
55
|
+
|
56
|
+
@pool.shutdown
|
57
|
+
@pool.wait_for_termination if wait
|
58
|
+
end
|
59
|
+
|
60
|
+
def shutdown?
|
61
|
+
!@pool.running?
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def create_pool
|
67
|
+
@pool = Concurrent::ThreadPoolExecutor.new(POOL_OPTIONS)
|
68
|
+
end
|
69
|
+
|
70
|
+
def listen
|
71
|
+
future = Concurrent::Future.new(args: [@recipients, @pool, @listening], executor: @pool) do |recipients, pool, listening|
|
72
|
+
Rails.application.reloader.wrap do
|
73
|
+
conn = ActiveRecord::Base.connection.raw_connection
|
74
|
+
ActiveSupport::Notifications.instrument("notifier_listen.good_job") do
|
75
|
+
conn.async_exec "LISTEN #{CHANNEL}"
|
76
|
+
end
|
77
|
+
|
78
|
+
begin
|
79
|
+
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
|
80
|
+
while pool.running?
|
81
|
+
listening.make_true
|
82
|
+
conn.wait_for_notify(WAIT_INTERVAL) do |channel, _pid, payload|
|
83
|
+
listening.make_false
|
84
|
+
next unless channel == CHANNEL
|
85
|
+
|
86
|
+
ActiveSupport::Notifications.instrument("notifier_notified.good_job", { payload: payload })
|
87
|
+
parsed_payload = JSON.parse(payload, symbolize_names: true)
|
88
|
+
recipients.each do |recipient|
|
89
|
+
target, method_name = recipient.is_a?(Array) ? recipient : [recipient, :call]
|
90
|
+
target.send(method_name, parsed_payload)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
listening.make_false
|
94
|
+
end
|
95
|
+
end
|
96
|
+
rescue StandardError => e
|
97
|
+
ActiveSupport::Notifications.instrument("notifier_notify_error.good_job", { error: e })
|
98
|
+
raise
|
99
|
+
ensure
|
100
|
+
@listening.make_false
|
101
|
+
ActiveSupport::Notifications.instrument("notifier_unlisten.good_job") do
|
102
|
+
conn.async_exec "UNLISTEN *"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
future.add_observer(self, :listen_observer)
|
109
|
+
future.execute
|
110
|
+
end
|
111
|
+
|
112
|
+
def listen_observer(_time, _result, _thread_error)
|
113
|
+
listen unless shutdown?
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
data/lib/good_job/performer.rb
CHANGED
@@ -2,14 +2,21 @@ module GoodJob
|
|
2
2
|
class Performer
|
3
3
|
attr_reader :name
|
4
4
|
|
5
|
-
def initialize(target, method_name, name: nil)
|
5
|
+
def initialize(target, method_name, name: nil, filter: nil)
|
6
6
|
@target = target
|
7
7
|
@method_name = method_name
|
8
8
|
@name = name
|
9
|
+
@filter = filter
|
9
10
|
end
|
10
11
|
|
11
12
|
def next
|
12
13
|
@target.public_send(@method_name)
|
13
14
|
end
|
15
|
+
|
16
|
+
def next?(state = {})
|
17
|
+
return true unless @filter.respond_to?(:call)
|
18
|
+
|
19
|
+
@filter.call(state)
|
20
|
+
end
|
14
21
|
end
|
15
22
|
end
|
data/lib/good_job/railtie.rb
CHANGED
@@ -4,5 +4,15 @@ module GoodJob
|
|
4
4
|
ActiveSupport.on_load(:good_job) { self.logger = ::Rails.logger }
|
5
5
|
GoodJob::LogSubscriber.attach_to :good_job
|
6
6
|
end
|
7
|
+
|
8
|
+
initializer "good_job.active_job_notifications" do
|
9
|
+
ActiveSupport::Notifications.subscribe "enqueue_retry.active_job" do |event|
|
10
|
+
GoodJob::CurrentExecution.error_on_retry = event.payload[:error]
|
11
|
+
end
|
12
|
+
|
13
|
+
ActiveSupport::Notifications.subscribe "discard.active_job" do |event|
|
14
|
+
GoodJob::CurrentExecution.error_on_discard = event.payload[:error]
|
15
|
+
end
|
16
|
+
end
|
7
17
|
end
|
8
18
|
end
|
data/lib/good_job/scheduler.rb
CHANGED
@@ -2,16 +2,25 @@ require "concurrent/executor/thread_pool_executor"
|
|
2
2
|
require "concurrent/timer_task"
|
3
3
|
require "concurrent/utility/processor_counter"
|
4
4
|
|
5
|
-
module GoodJob
|
5
|
+
module GoodJob # :nodoc:
|
6
|
+
#
|
7
|
+
# Schedulers are generic thread execution pools that are responsible for
|
8
|
+
# periodically checking for available execution tasks, executing tasks in a
|
9
|
+
# bounded thread-pool, and efficiently scaling execution threads.
|
10
|
+
#
|
11
|
+
# Schedulers are "generic" in the sense that they delegate task execution
|
12
|
+
# details to a "Performer" object that responds to #next.
|
13
|
+
#
|
6
14
|
class Scheduler
|
15
|
+
# Defaults for instance of Concurrent::TimerTask
|
7
16
|
DEFAULT_TIMER_OPTIONS = {
|
8
17
|
execution_interval: 1,
|
9
18
|
timeout_interval: 1,
|
10
19
|
run_now: true,
|
11
20
|
}.freeze
|
12
21
|
|
22
|
+
# Defaults for instance of Concurrent::ThreadPoolExecutor
|
13
23
|
DEFAULT_POOL_OPTIONS = {
|
14
|
-
name: 'good_job',
|
15
24
|
min_threads: 0,
|
16
25
|
max_threads: Concurrent.processor_count,
|
17
26
|
auto_terminate: true,
|
@@ -20,8 +29,53 @@ module GoodJob
|
|
20
29
|
fallback_policy: :discard,
|
21
30
|
}.freeze
|
22
31
|
|
32
|
+
# @!attribute [r] instances
|
33
|
+
# @!scope class
|
34
|
+
# All instantiated Schedulers in the current process.
|
35
|
+
# @return [array<GoodJob:Scheduler>]
|
23
36
|
cattr_reader :instances, default: [], instance_reader: false
|
24
37
|
|
38
|
+
# Creates GoodJob::Scheduler(s) and Performers from a GoodJob::Configuration instance.
|
39
|
+
# @param configuration [GoodJob::Configuration]
|
40
|
+
# @return [GoodJob::Scheduler, GoodJob::MultiScheduler]
|
41
|
+
def self.from_configuration(configuration)
|
42
|
+
schedulers = configuration.queue_string.split(';').map do |queue_string_and_max_threads|
|
43
|
+
queue_string, max_threads = queue_string_and_max_threads.split(':')
|
44
|
+
max_threads = (max_threads || configuration.max_threads).to_i
|
45
|
+
|
46
|
+
job_query = GoodJob::Job.queue_string(queue_string)
|
47
|
+
parsed = GoodJob::Job.queue_parser(queue_string)
|
48
|
+
job_filter = proc do |state|
|
49
|
+
if parsed[:exclude]
|
50
|
+
!parsed[:exclude].include? state[:queue_name]
|
51
|
+
elsif parsed[:include]
|
52
|
+
parsed[:include].include? state[:queue_name]
|
53
|
+
else
|
54
|
+
true
|
55
|
+
end
|
56
|
+
end
|
57
|
+
job_performer = GoodJob::Performer.new(job_query, :perform_with_advisory_lock, name: queue_string, filter: job_filter)
|
58
|
+
|
59
|
+
timer_options = {}
|
60
|
+
timer_options[:execution_interval] = configuration.poll_interval
|
61
|
+
|
62
|
+
pool_options = {
|
63
|
+
max_threads: max_threads,
|
64
|
+
}
|
65
|
+
|
66
|
+
GoodJob::Scheduler.new(job_performer, timer_options: timer_options, pool_options: pool_options)
|
67
|
+
end
|
68
|
+
|
69
|
+
if schedulers.size > 1
|
70
|
+
GoodJob::MultiScheduler.new(schedulers)
|
71
|
+
else
|
72
|
+
schedulers.first
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# @param performer [GoodJob::Performer]
|
77
|
+
# @param timer_options [Hash] Options to instantiate a Concurrent::TimerTask
|
78
|
+
# @param pool_options [Hash] Options to instantiate a Concurrent::ThreadPoolExecutor
|
25
79
|
def initialize(performer, timer_options: {}, pool_options: {})
|
26
80
|
raise ArgumentError, "Performer argument must implement #next" unless performer.respond_to?(:next)
|
27
81
|
|
@@ -31,14 +85,19 @@ module GoodJob
|
|
31
85
|
@pool_options = DEFAULT_POOL_OPTIONS.merge(pool_options)
|
32
86
|
@timer_options = DEFAULT_TIMER_OPTIONS.merge(timer_options)
|
33
87
|
|
88
|
+
@pool_options[:name] = "GoodJob::Scheduler(queues=#{@performer.name} max_threads=#{@pool_options[:max_threads]} poll_interval=#{@timer_options[:execution_interval]})"
|
89
|
+
|
34
90
|
create_pools
|
35
91
|
end
|
36
92
|
|
93
|
+
# Shut down the Scheduler.
|
94
|
+
# @param wait [Boolean] Wait for actively executing jobs to finish
|
95
|
+
# @return [void]
|
37
96
|
def shutdown(wait: true)
|
38
97
|
@_shutdown = true
|
39
98
|
|
40
|
-
|
41
|
-
|
99
|
+
instrument("scheduler_shutdown_start", { wait: wait })
|
100
|
+
instrument("scheduler_shutdown", { wait: wait }) do
|
42
101
|
if @timer&.running?
|
43
102
|
@timer.shutdown
|
44
103
|
@timer.wait_for_termination if wait
|
@@ -51,19 +110,32 @@ module GoodJob
|
|
51
110
|
end
|
52
111
|
end
|
53
112
|
|
113
|
+
# True when the Scheduler is shutdown.
|
114
|
+
# @return [true, false, nil]
|
54
115
|
def shutdown?
|
55
116
|
@_shutdown
|
56
117
|
end
|
57
118
|
|
119
|
+
# Restart the Scheduler. When shutdown, start; or shutdown and start.
|
120
|
+
# @param wait [Boolean] Wait for actively executing jobs to finish
|
121
|
+
# @return [void]
|
58
122
|
def restart(wait: true)
|
59
|
-
|
123
|
+
instrument("scheduler_restart_pools") do
|
60
124
|
shutdown(wait: wait) unless shutdown?
|
61
125
|
create_pools
|
126
|
+
@_shutdown = false
|
62
127
|
end
|
63
128
|
end
|
64
129
|
|
65
|
-
|
66
|
-
|
130
|
+
# Triggers a Performer execution, if an execution thread is available.
|
131
|
+
# @param state [nil, Object] Allows Performer#next? to accept or reject the execution
|
132
|
+
# @return [nil, Boolean] if the thread was created
|
133
|
+
def create_thread(state = nil)
|
134
|
+
return nil unless @pool.running? && @pool.ready_worker_count.positive?
|
135
|
+
|
136
|
+
if state
|
137
|
+
return false unless @performer.next?(state)
|
138
|
+
end
|
67
139
|
|
68
140
|
future = Concurrent::Future.new(args: [@performer], executor: @pool) do |performer|
|
69
141
|
output = nil
|
@@ -72,35 +144,32 @@ module GoodJob
|
|
72
144
|
end
|
73
145
|
future.add_observer(self, :task_observer)
|
74
146
|
future.execute
|
147
|
+
|
148
|
+
true
|
75
149
|
end
|
76
150
|
|
151
|
+
# Invoked on completion of TimerTask task.
|
152
|
+
# @!visibility private
|
153
|
+
# @return [void]
|
77
154
|
def timer_observer(time, executed_task, thread_error)
|
78
155
|
GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
|
79
|
-
|
156
|
+
instrument("finished_timer_task", { result: executed_task, error: thread_error, time: time })
|
80
157
|
end
|
81
158
|
|
159
|
+
# Invoked on completion of ThreadPoolExecutor task
|
160
|
+
# @!visibility private
|
161
|
+
# @return [void]
|
82
162
|
def task_observer(time, output, thread_error)
|
83
163
|
GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
|
84
|
-
|
164
|
+
instrument("finished_job_task", { result: output, error: thread_error, time: time })
|
85
165
|
create_thread if output
|
86
166
|
end
|
87
167
|
|
88
|
-
class ThreadPoolExecutor < Concurrent::ThreadPoolExecutor
|
89
|
-
# https://github.com/ruby-concurrency/concurrent-ruby/issues/684#issuecomment-427594437
|
90
|
-
def ready_worker_count
|
91
|
-
synchronize do
|
92
|
-
workers_still_to_be_created = @max_length - @pool.length
|
93
|
-
workers_created_but_waiting = @ready.length
|
94
|
-
|
95
|
-
workers_still_to_be_created + workers_created_but_waiting
|
96
|
-
end
|
97
|
-
end
|
98
|
-
end
|
99
|
-
|
100
168
|
private
|
101
169
|
|
170
|
+
# @return [void]
|
102
171
|
def create_pools
|
103
|
-
|
172
|
+
instrument("scheduler_create_pools", { performer_name: @performer.name, max_threads: @pool_options[:max_threads], poll_interval: @timer_options[:execution_interval] }) do
|
104
173
|
@pool = ThreadPoolExecutor.new(@pool_options)
|
105
174
|
next unless @timer_options[:execution_interval].positive?
|
106
175
|
|
@@ -110,12 +179,29 @@ module GoodJob
|
|
110
179
|
end
|
111
180
|
end
|
112
181
|
|
113
|
-
def
|
114
|
-
|
182
|
+
def instrument(name, payload = {}, &block)
|
183
|
+
payload = payload.reverse_merge({
|
184
|
+
scheduler: self,
|
185
|
+
process_id: GoodJob::CurrentExecution.process_id,
|
186
|
+
thread_name: GoodJob::CurrentExecution.thread_name,
|
187
|
+
})
|
188
|
+
|
189
|
+
ActiveSupport::Notifications.instrument("#{name}.good_job", payload, &block)
|
115
190
|
end
|
191
|
+
end
|
116
192
|
|
117
|
-
|
118
|
-
|
193
|
+
# Slightly customized sub-class of Concurrent::ThreadPoolExecutor
|
194
|
+
class ThreadPoolExecutor < Concurrent::ThreadPoolExecutor
|
195
|
+
# Number of idle or potential threads available to execute tasks
|
196
|
+
# https://github.com/ruby-concurrency/concurrent-ruby/issues/684#issuecomment-427594437
|
197
|
+
# @return [Integer]
|
198
|
+
def ready_worker_count
|
199
|
+
synchronize do
|
200
|
+
workers_still_to_be_created = @max_length - @pool.length
|
201
|
+
workers_created_but_waiting = @ready.length
|
202
|
+
|
203
|
+
workers_still_to_be_created + workers_created_but_waiting
|
204
|
+
end
|
119
205
|
end
|
120
206
|
end
|
121
207
|
end
|
data/lib/good_job/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: good_job
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ben Sheldon
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-08-
|
11
|
+
date: 2020-08-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: concurrent-ruby
|
@@ -151,7 +151,7 @@ dependencies:
|
|
151
151
|
- !ruby/object:Gem::Version
|
152
152
|
version: '0'
|
153
153
|
- !ruby/object:Gem::Dependency
|
154
|
-
name: pry
|
154
|
+
name: pry-rails
|
155
155
|
requirement: !ruby/object:Gem::Requirement
|
156
156
|
requirements:
|
157
157
|
- - ">="
|
@@ -178,6 +178,20 @@ dependencies:
|
|
178
178
|
- - ">="
|
179
179
|
- !ruby/object:Gem::Version
|
180
180
|
version: '0'
|
181
|
+
- !ruby/object:Gem::Dependency
|
182
|
+
name: rbtrace
|
183
|
+
requirement: !ruby/object:Gem::Requirement
|
184
|
+
requirements:
|
185
|
+
- - ">="
|
186
|
+
- !ruby/object:Gem::Version
|
187
|
+
version: '0'
|
188
|
+
type: :development
|
189
|
+
prerelease: false
|
190
|
+
version_requirements: !ruby/object:Gem::Requirement
|
191
|
+
requirements:
|
192
|
+
- - ">="
|
193
|
+
- !ruby/object:Gem::Version
|
194
|
+
version: '0'
|
181
195
|
- !ruby/object:Gem::Dependency
|
182
196
|
name: rspec-rails
|
183
197
|
requirement: !ruby/object:Gem::Requirement
|
@@ -248,6 +262,34 @@ dependencies:
|
|
248
262
|
- - ">="
|
249
263
|
- !ruby/object:Gem::Version
|
250
264
|
version: '0'
|
265
|
+
- !ruby/object:Gem::Dependency
|
266
|
+
name: sigdump
|
267
|
+
requirement: !ruby/object:Gem::Requirement
|
268
|
+
requirements:
|
269
|
+
- - ">="
|
270
|
+
- !ruby/object:Gem::Version
|
271
|
+
version: '0'
|
272
|
+
type: :development
|
273
|
+
prerelease: false
|
274
|
+
version_requirements: !ruby/object:Gem::Requirement
|
275
|
+
requirements:
|
276
|
+
- - ">="
|
277
|
+
- !ruby/object:Gem::Version
|
278
|
+
version: '0'
|
279
|
+
- !ruby/object:Gem::Dependency
|
280
|
+
name: yard
|
281
|
+
requirement: !ruby/object:Gem::Requirement
|
282
|
+
requirements:
|
283
|
+
- - ">="
|
284
|
+
- !ruby/object:Gem::Version
|
285
|
+
version: '0'
|
286
|
+
type: :development
|
287
|
+
prerelease: false
|
288
|
+
version_requirements: !ruby/object:Gem::Requirement
|
289
|
+
requirements:
|
290
|
+
- - ">="
|
291
|
+
- !ruby/object:Gem::Version
|
292
|
+
version: '0'
|
251
293
|
description: A multithreaded, Postgres-based ActiveJob backend for Ruby on Rails
|
252
294
|
email:
|
253
295
|
- bensheldon@gmail.com
|
@@ -269,9 +311,13 @@ files:
|
|
269
311
|
- lib/good_job.rb
|
270
312
|
- lib/good_job/adapter.rb
|
271
313
|
- lib/good_job/cli.rb
|
314
|
+
- lib/good_job/configuration.rb
|
315
|
+
- lib/good_job/current_execution.rb
|
272
316
|
- lib/good_job/job.rb
|
273
317
|
- lib/good_job/lockable.rb
|
274
318
|
- lib/good_job/log_subscriber.rb
|
319
|
+
- lib/good_job/multi_scheduler.rb
|
320
|
+
- lib/good_job/notifier.rb
|
275
321
|
- lib/good_job/performer.rb
|
276
322
|
- lib/good_job/pg_locks.rb
|
277
323
|
- lib/good_job/railtie.rb
|