good_job 1.11.3 → 1.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e0f0ca8d7f8b024f5e84b515b8603d362b6c755d54680221e517369b6877c07
4
- data.tar.gz: e69518c9d71d7b08e3707c9df8c6429ca4d9823fd1adea32adc6c0bf41ad33f4
3
+ metadata.gz: b9c8c8a0842bb8b25ee147d92186ca5596d4dc0bdc1a9a7638a094dc80c41806
4
+ data.tar.gz: 30b21a20dbe0b6d81854e29448b1218494359dfeaf8ebc4e0a51d4aaa87ca101
5
5
  SHA512:
6
- metadata.gz: 50d2542bfad082dfba516c08bd994dad3fcd6e8311a8380dc012988c3e2745378271adc7d41cb91897d087a6b2b100cfb69362d3a11cd55c92c2610ceabeb80c
7
- data.tar.gz: 69ffc79dc9367fc8522bdcf669cede6791d4e7903847edbc99887404b29ec43361de54a960841ee6cdeaaf236cc77d8c9c60b2c61793b5256bf2455667e0c387
6
+ metadata.gz: c2a0663d5af40fd294a9409c249677f73b683f5d50a3d2af58f120749429789de6f650ad58d61cc3704d0fa8a5ccc6681982ed106df37cf2d64856895e63892a
7
+ data.tar.gz: 0c8f717f2d763ecec47acc6dae85cadf153847d51cce0daf9c5b07f4aa953f4b57b612a79f0ae38335e9ed44192017567286448e761f1bfa688a5a6f04401820
data/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # Changelog
2
2
 
3
+ ## [v1.13.0](https://github.com/bensheldon/good_job/tree/v1.13.0) (2021-08-18)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.12.2...v1.13.0)
6
+
7
+ **Implemented enhancements:**
8
+
9
+ - Track if a GoodJob::Job has been subsequently retried [\#331](https://github.com/bensheldon/good_job/pull/331) ([bensheldon](https://github.com/bensheldon))
10
+ - Wrap and truncate error message, which can be a huge text [\#294](https://github.com/bensheldon/good_job/pull/294) ([morgoth](https://github.com/morgoth))
11
+
12
+ **Closed issues:**
13
+
14
+ - Add hyphen to lock string. e.g. "table\_name-column" instead of "table\_namecolumn [\#334](https://github.com/bensheldon/good_job/issues/334)
15
+ - Optimize db indexes in advance of v2.0.0 [\#332](https://github.com/bensheldon/good_job/issues/332)
16
+ - wait\_until in development? [\#330](https://github.com/bensheldon/good_job/issues/330)
17
+ - Race conditions in ActiveJob concurrency extension [\#325](https://github.com/bensheldon/good_job/issues/325)
18
+ - Store in database if a job has been ActiveJob retried [\#321](https://github.com/bensheldon/good_job/issues/321)
19
+ - Revisit and embrace concurrency control, scheduled jobs, and other extensions of ActiveJob [\#255](https://github.com/bensheldon/good_job/issues/255)
20
+ - Why 1 million jobs per day? [\#222](https://github.com/bensheldon/good_job/issues/222)
21
+
22
+ ## [v1.12.2](https://github.com/bensheldon/good_job/tree/v1.12.2) (2021-08-13)
23
+
24
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.12.1...v1.12.2)
25
+
26
+ **Fixed bugs:**
27
+
28
+ - Fixes for race conditions in ActiveJob concurrency extension [\#326](https://github.com/bensheldon/good_job/pull/326) ([codyrobbins](https://github.com/codyrobbins))
29
+
30
+ **Merged pull requests:**
31
+
32
+ - On gem release, add instructions to author a Github Release [\#324](https://github.com/bensheldon/good_job/pull/324) ([bensheldon](https://github.com/bensheldon))
33
+
34
+ ## [v1.12.1](https://github.com/bensheldon/good_job/tree/v1.12.1) (2021-08-05)
35
+
36
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.12.0...v1.12.1)
37
+
38
+ **Fixed bugs:**
39
+
40
+ - Ensure CLI can shutdown cleanly with multiple queues and timeout [\#319](https://github.com/bensheldon/good_job/pull/319) ([bensheldon](https://github.com/bensheldon))
41
+
42
+ **Closed issues:**
43
+
44
+ - Setting a shutdown timeout causes the CLI executor to throw an exception on shutdown. [\#318](https://github.com/bensheldon/good_job/issues/318)
45
+ - PgBouncer and prepared statements [\#269](https://github.com/bensheldon/good_job/issues/269)
46
+ - Question about locking internals [\#212](https://github.com/bensheldon/good_job/issues/212)
47
+ - Encoding::UndefinedConversionError \("\xE2" from ASCII-8BIT to UTF-8\) [\#198](https://github.com/bensheldon/good_job/issues/198)
48
+ - tools for managing a 'fleet' of processes [\#150](https://github.com/bensheldon/good_job/issues/150)
49
+
50
+ **Merged pull requests:**
51
+
52
+ - Fix Readme lint warnings [\#320](https://github.com/bensheldon/good_job/pull/320) ([bensheldon](https://github.com/bensheldon))
53
+
54
+ ## [v1.12.0](https://github.com/bensheldon/good_job/tree/v1.12.0) (2021-07-27)
55
+
56
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.11.3...v1.12.0)
57
+
58
+ **Implemented enhancements:**
59
+
60
+ - Add the ability to schedule repeating / recurring / cron-like jobs [\#53](https://github.com/bensheldon/good_job/issues/53)
61
+ - Add cron-like support for recurring/repeating jobs [\#297](https://github.com/bensheldon/good_job/pull/297) ([bensheldon](https://github.com/bensheldon))
62
+
63
+ **Fixed bugs:**
64
+
65
+ - Place Dashboard shared view partials under `good_job` namespace [\#310](https://github.com/bensheldon/good_job/pull/310) ([bensheldon](https://github.com/bensheldon))
66
+ - Ensure Dashboard inline javascript has CSP nonce for strict Content-Security Policy [\#309](https://github.com/bensheldon/good_job/pull/309) ([bensheldon](https://github.com/bensheldon))
67
+
3
68
  ## [v1.11.3](https://github.com/bensheldon/good_job/tree/v1.11.3) (2021-07-25)
4
69
 
5
70
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.11.2...v1.11.3)
data/README.md CHANGED
@@ -10,7 +10,7 @@ GoodJob is a multithreaded, Postgres-based, ActiveJob backend for Ruby on Rails.
10
10
  - **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.
11
11
  - **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).
12
12
  - **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.
13
- - **For most workloads.** Targets full-stack teams, economy-minded solo developers, and applications that enqueue less than 1-million jobs/day.
13
+ - **For most workloads.** Targets full-stack teams, economy-minded solo developers, and applications that enqueue 1-million jobs/day and more.
14
14
 
15
15
  For more of the story of GoodJob, read the [introductory blog post](https://island94.org/2020/07/introducing-goodjob-1-0).
16
16
 
@@ -38,7 +38,8 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
38
38
  - [Configuration options](#configuration-options)
39
39
  - [Global options](#global-options)
40
40
  - [Dashboard](#dashboard)
41
- - [ActiveJob Concurrency](#activejob-concurrency)
41
+ - [ActiveJob concurrency](#activejob-concurrency)
42
+ - [Cron-style repeating/recurring jobs](#cron-style-repeatingrecurring-jobs)
42
43
  - [Updating](#updating)
43
44
  - [Go deeper](#go-deeper)
44
45
  - [Exceptions, retries, and reliability](#exceptions-retries-and-reliability)
@@ -51,6 +52,7 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
51
52
  - [Execute jobs async / in-process](#execute-jobs-async--in-process)
52
53
  - [Migrate to GoodJob from a different ActiveJob backend](#migrate-to-goodjob-from-a-different-activejob-backend)
53
54
  - [Monitor and preserve worked jobs](#monitor-and-preserve-worked-jobs)
55
+ - [PgBouncer compatibility](#pgbouncer-compatibility)
54
56
  - [Contribute](#contribute)
55
57
  - [Gem development](#gem-development)
56
58
  - [Release](#release)
@@ -156,6 +158,7 @@ Options:
156
158
  [--poll-interval=SECONDS] # Interval between polls for available jobs in seconds (env var: GOOD_JOB_POLL_INTERVAL, default: 1)
157
159
  [--max-cache=COUNT] # Maximum number of scheduled jobs to cache in memory (env var: GOOD_JOB_MAX_CACHE, default: 10000)
158
160
  [--shutdown-timeout=SECONDS] # Number of seconds to wait for jobs to finish when shutting down before stopping the thread. (env var: GOOD_JOB_SHUTDOWN_TIMEOUT, default: -1 (forever))
161
+ [--enable-cron] # Whether to run cron process (default: false)
159
162
  [--daemonize] # Run as a background daemon (default: false)
160
163
  [--pidfile=PIDFILE] # Path to write daemonized Process ID (env var: GOOD_JOB_PIDFILE, default: tmp/pids/good_job.pid)
161
164
 
@@ -212,7 +215,8 @@ config.good_job.execution_mode = :async_server
212
215
  config.good_job.max_threads = 5
213
216
  config.good_job.poll_interval = 30 # seconds
214
217
  config.good_job.shutdown_timeout = 25 # seconds
215
-
218
+ config.good_job.enable_cron = true
219
+ config.good_job.cron = { example: { cron: '0 * * * *', class: 'ExampleJob' } }
216
220
 
217
221
  # ...or all at once.
218
222
  config.good_job = {
@@ -220,6 +224,13 @@ config.good_job = {
220
224
  max_threads: 5,
221
225
  poll_interval: 30,
222
226
  shutdown_timeout: 25,
227
+ enable_cron: true,
228
+ cron: {
229
+ example: {
230
+ cron: '0 * * * *',
231
+ class: 'ExampleJob'
232
+ },
233
+ },
223
234
  }
224
235
  ```
225
236
 
@@ -235,6 +246,8 @@ Available configuration options are:
235
246
  - `poll_interval` (integer) sets the number of seconds between polls for jobs when `execution_mode` is set to `:async` or `:async_server`. You can also set this with the environment variable `GOOD_JOB_POLL_INTERVAL`.
236
247
  - `max_cache` (integer) sets the maximum number of scheduled jobs that will be stored in memory to reduce execution latency when also polling for scheduled jobs. Caching 10,000 scheduled jobs uses approximately 20MB of memory. You can also set this with the environment variable `GOOD_JOB_MAX_CACHE`.
237
248
  - `shutdown_timeout` (float) number of seconds to wait for jobs to finish when shutting down before stopping the thread. Defaults to forever: `-1`. You can also set this with the environment variable `GOOD_JOB_SHUTDOWN_TIMEOUT`.
249
+ - `enable_cron` (boolean) whether to run cron process. Defaults to `false`. You can also set this with the environment variable `GOOD_JOB_ENABLE_CRON`.
250
+ - `cron` (hash) cron configuration. Defaults to `{}`. You can also set this as a JSON string with the environment variable `GOOD_JOB_CRON`
238
251
 
239
252
  By default, GoodJob configures the following execution modes per environment:
240
253
 
@@ -320,9 +333,9 @@ GoodJob includes a Dashboard as a mountable `Rails::Engine`.
320
333
  end
321
334
  ```
322
335
 
323
- ### ActiveJob Concurrency
336
+ ### ActiveJob concurrency
324
337
 
325
- GoodJob can extend ActiveJob to provide limits on concurrently running jobs, either at time of _enqueue_ or at _perform_.
338
+ GoodJob can extend ActiveJob to provide limits on concurrently running jobs, either at time of _enqueue_ or at _perform_. Limiting concurrency can help prevent duplicate, double or unecessary jobs from being enqueued, or race conditions when performing, for example when interacting with 3rd-party APIs.
326
339
 
327
340
  **Note:** Limiting concurrency at _enqueue_ requires Rails 6.0+ because Rails 5.2 does not support `throw :abort` in ActiveJob callbacks.
328
341
 
@@ -349,6 +362,45 @@ class MyJob < ApplicationJob
349
362
  end
350
363
  ```
351
364
 
365
+ When testing, the resulting concurrency key value can be inspected:
366
+
367
+ ```ruby
368
+ job = MyJob.perform_later("Alice")
369
+ job.good_job_concurrency_key #=> "Unique-Alice"
370
+ ```
371
+
372
+ ### Cron-style repeating/recurring jobs
373
+
374
+ GoodJob can enqueue jobs on a recurring basis that can be used as a replacement for cron.
375
+
376
+ Cron-style jobs are run on every GoodJob process (e.g. CLI or `async` execution mode) when `config.good_job.enable_cron = true`; use GoodJob's [ActiveJob concurrency](#activejob-concurrency) extension to limit the number of jobs that are enqueued.
377
+
378
+ Cron-format is parsed by the [`fugit`](https://github.com/floraison/fugit) gem, which has support for seconds-level resolution (e.g. `* * * * * *`).
379
+
380
+ ```ruby
381
+ # config/environments/application.rb or a specific environment e.g. production.rb
382
+
383
+ # Enable cron in this process; e.g. only run on the first Heroku worker process
384
+ config.good_job.enable_cron = ENV['DYNO'] == 'worker.1' # or `true` or via $GOOD_JOB_ENABLE_CRON
385
+
386
+ # Configure cron with a hash that has a unique key for each recurring job
387
+ config.good_job.cron = {
388
+ # Every 15 minutes, enqueue `ExampleJob.set(priority: -10).perform_later(52, name: "Alice")`
389
+ frequent_task: { # each recurring job must have a unique key
390
+ cron: "*/15 * * * *", # cron-style scheduling format by fugit gem
391
+ class: "ExampleJob", # reference the Job class with a string
392
+ args: [42, { name: "Alice" }], # arguments to pass; can also be a proc e.g. `-> { { when: Time.now } }`
393
+ set: { priority: -10 }, # additional ActiveJob properties; can also be a lambda/proc e.g. `-> { { priority: [1,2].sample } }`
394
+ description: "Something helpful", # optional description that appears in Dashboard (coming soon!)
395
+ },
396
+ another_task: {
397
+ cron: "0 0,12 * * *",
398
+ class: "AnotherJob",
399
+ },
400
+ # etc.
401
+ }
402
+ ```
403
+
352
404
  ### Updating
353
405
 
354
406
  GoodJob follows semantic versioning, though updates may be encouraged through deprecation warnings in minor versions.
@@ -636,6 +688,43 @@ It is also necessary to delete these preserved jobs from the database after a ce
636
688
  $ bundle exec good_job cleanup_preserved_jobs --before-seconds-ago=86400
637
689
  ```
638
690
 
691
+ ### PgBouncer compatibility
692
+
693
+ GoodJob is not compatible with PgBouncer in _transaction_ mode, but is compatible with PgBouncer's _connection_ mode. GoodJob uses connection-based advisory locks and LISTEN/NOTIFY, both of which require full database connections.
694
+
695
+ A workaround to this limitation is to make a direct database connection available to GoodJob. With Rails 6.0's support for [multiple databases](https://guides.rubyonrails.org/active_record_multiple_databases.html), a direct connection to the database can be configured:
696
+
697
+ 1. Define a direct connection to your database that is not proxied through PgBouncer, for example:
698
+
699
+ ```yml
700
+ # config/database.yml
701
+
702
+ production:
703
+ primary:
704
+ url: postgres://pgbouncer_host/my_database
705
+ primary_direct:
706
+ url: postgres://database_host/my_database
707
+ ```
708
+
709
+ 1. Create a new ActiveRecord base class that uses the direct database connection
710
+
711
+ ```ruby
712
+ # app/models/application_direct_record.rb
713
+
714
+ class ApplicationDirectRecord < ActiveRecord::Base
715
+ self.abstract_class = true
716
+ connects_to database: :primary_direct
717
+ end
718
+ ```
719
+
720
+ 1. Configure GoodJob to use the newly created ActiveRecord base class:
721
+
722
+ ```ruby
723
+ # config/initializers/good_job.rb
724
+
725
+ GoodJob.active_record_parent_class = "ApplicationDirectRecord"
726
+ ```
727
+
639
728
  ## Contribute
640
729
 
641
730
  Contributions are welcomed and appreciated 🙏
@@ -1 +1 @@
1
- <%= render 'shared/jobs_table', jobs: @jobs %>
1
+ <%= render 'good_job/shared/jobs_table', jobs: @jobs %>
@@ -1,5 +1,5 @@
1
1
  <div class="card my-3 p-6">
2
- <%= render 'shared/chart', chart_data: @chart %>
2
+ <%= render 'good_job/shared/chart', chart_data: @chart %>
3
3
  </div>
4
4
 
5
5
  <div class='card mb-2'>
@@ -38,7 +38,7 @@
38
38
  </div>
39
39
 
40
40
  <% if @filter.jobs.present? %>
41
- <%= render 'shared/jobs_table', jobs: @filter.jobs %>
41
+ <%= render 'good_job/shared/jobs_table', jobs: @filter.jobs %>
42
42
 
43
43
  <nav aria-label="Job pagination" class="mt-3">
44
44
  <ul class="pagination">
@@ -1,6 +1,6 @@
1
1
  <div id="chart"></div>
2
2
 
3
- <script>
3
+ <%= javascript_tag nonce: true do %>
4
4
  new Chartist.Line('#chart', <%== chart_data.to_json %>, {
5
5
  height: '300px',
6
6
  fullWidth: true,
@@ -49,4 +49,4 @@
49
49
  tooltipEl.style.left = (event.offsetX || event.originalEvent.layerX) + tooltipEl.offsetWidth + 10 + 'px';
50
50
  tooltipEl.style.top = (event.offsetY || event.originalEvent.layerY) + tooltipEl.offsetHeight - 20 + 'px';
51
51
  }, true);
52
- </script>
52
+ <% end %>
@@ -19,7 +19,7 @@
19
19
  <td><%= job.serialized_params['job_class'] %></td>
20
20
  <td><%= job.queue_name %></td>
21
21
  <td><%= job.scheduled_at || job.created_at %></td>
22
- <td><%= job.error %></td>
22
+ <td class="text-break"><%= truncate(job.error, length: 1_000) %></td>
23
23
  <td>
24
24
  <%= tag.button "Preview", type: "button", class: "btn btn-sm btn-outline-primary", role: "button",
25
25
  data: {bs_toggle: "collapse", bs_target: "##{dom_id(job, 'params')}"},
@@ -29,7 +29,7 @@
29
29
  </td>
30
30
  <td>
31
31
  <%= button_to job_path(job.id), method: :delete, class: "btn btn-sm btn-outline-danger", title: "Delete job" do %>
32
- <%= render "shared/icons/trash" %>
32
+ <%= render "good_job/shared/icons/trash" %>
33
33
  <% end %>
34
34
  </td>
35
35
  </tr>
@@ -53,13 +53,13 @@
53
53
 
54
54
  <% if notice %>
55
55
  <div class="alert alert-success alert-dismissible fade show d-flex align-items-center offset-md-3 col-6" role="alert">
56
- <%= render "shared/icons/check", class: "flex-shrink-0 me-2" %>
56
+ <%= render "good_job/shared/icons/check", class: "flex-shrink-0 me-2" %>
57
57
  <div><%= notice %></div>
58
58
  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
59
59
  </div>
60
60
  <% elsif alert %>
61
61
  <div class="alert alert-warning alert-dismissible fade show d-flex align-items-center offset-md-3 col-6" role="alert">
62
- <%= render "shared/icons/check", class: "flex-shrink-0 me-2" %>
62
+ <%= render "good_job/shared/icons/check", class: "flex-shrink-0 me-2" %>
63
63
  <div><%= alert %></div>
64
64
  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
65
65
  </div>
@@ -16,6 +16,7 @@ class CreateGoodJobs < ActiveRecord::Migration[5.2]
16
16
  t.uuid :active_job_id
17
17
  t.text :concurrency_key
18
18
  t.text :cron_key
19
+ t.uuid :retried_good_job_id
19
20
  end
20
21
 
21
22
  add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: "index_good_jobs_on_scheduled_at"
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ class AddRetriedGoodJobIdToGoodJobs < ActiveRecord::Migration[5.2]
3
+ def change
4
+ reversible do |dir|
5
+ dir.up do
6
+ # Ensure this incremental update migration is idempotent
7
+ # with monolithic install migration.
8
+ return if connection.column_exists?(:good_jobs, :retried_good_job_id)
9
+ end
10
+ end
11
+
12
+ add_column :good_jobs, :retried_good_job_id, :uuid
13
+ end
14
+ end
data/lib/good_job.rb CHANGED
@@ -106,16 +106,13 @@ module GoodJob
106
106
  wait ? -1 : nil
107
107
  end
108
108
 
109
- executables = Array(Notifier.instances) + Array(Poller.instances) + Array(Scheduler.instances)
110
- _shutdown_all(executables, timeout: timeout)
109
+ _shutdown_all(_executables, timeout: timeout)
111
110
  end
112
111
 
113
112
  # Tests whether jobs have stopped executing.
114
113
  # @return [Boolean] whether background threads are shut down
115
114
  def self.shutdown?
116
- Notifier.instances.all?(&:shutdown?) &&
117
- Poller.instances.all?(&:shutdown?) &&
118
- Scheduler.instances.all?(&:shutdown?)
115
+ _executables.all?(&:shutdown?)
119
116
  end
120
117
 
121
118
  # Stops and restarts executing jobs.
@@ -126,17 +123,16 @@ module GoodJob
126
123
  # @param timeout [Numeric, nil] Seconds to wait for active threads to finish.
127
124
  # @return [void]
128
125
  def self.restart(timeout: -1)
129
- executables = Array(Notifier.instances) + Array(Poller.instances) + Array(Scheduler.instances)
130
- _shutdown_all(executables, :restart, timeout: timeout)
126
+ _shutdown_all(_executables, :restart, timeout: timeout)
131
127
  end
132
128
 
133
- # Sends +#shutdown+ or +#restart+ to executable objects ({GoodJob::Notifier}, {GoodJob::Poller}, {GoodJob::Scheduler})
134
- # @param executables [Array<Notifier, Poller, Scheduler, MultiScheduler>] Objects to shut down.
129
+ # Sends +#shutdown+ or +#restart+ to executable objects ({GoodJob::Notifier}, {GoodJob::Poller}, {GoodJob::Scheduler}, {GoodJob::MultiScheduler}, {GoodJob::CronManager})
130
+ # @param executables [Array<Notifier, Poller, Scheduler, MultiScheduler, CronManager>] Objects to shut down.
135
131
  # @param method_name [:symbol] Method to call, e.g. +:shutdown+ or +:restart+.
136
132
  # @param timeout [nil,Numeric]
137
133
  # @return [void]
138
134
  def self._shutdown_all(executables, method_name = :shutdown, timeout: -1)
139
- if timeout.positive?
135
+ if timeout.is_a?(Numeric) && timeout.positive?
140
136
  executables.each { |executable| executable.send(method_name, timeout: nil) }
141
137
 
142
138
  stop_at = Time.current + timeout
@@ -146,5 +142,14 @@ module GoodJob
146
142
  end
147
143
  end
148
144
 
145
+ def self._executables
146
+ [].concat(
147
+ CronManager.instances,
148
+ Notifier.instances,
149
+ Poller.instances,
150
+ Scheduler.instances
151
+ )
152
+ end
153
+
149
154
  ActiveSupport.run_load_hooks(:good_job, self)
150
155
  end
@@ -9,21 +9,21 @@ module GoodJob
9
9
  included do
10
10
  class_attribute :good_job_concurrency_config, instance_accessor: false, default: {}
11
11
 
12
- before_enqueue do |job|
12
+ around_enqueue do |job, block|
13
13
  # Always allow jobs to be retried because the current job's execution will complete momentarily
14
- next if CurrentExecution.active_job_id == job.job_id
14
+ next(block.call) if CurrentExecution.active_job_id == job.job_id
15
15
 
16
16
  limit = job.class.good_job_concurrency_config.fetch(:enqueue_limit, Float::INFINITY)
17
- next if limit.blank? || (0...Float::INFINITY).exclude?(limit)
17
+ next(block.call) if limit.blank? || (0...Float::INFINITY).exclude?(limit)
18
18
 
19
19
  key = job.good_job_concurrency_key
20
- next if key.blank?
20
+ next(block.call) if key.blank?
21
21
 
22
22
  GoodJob::Job.new.with_advisory_lock(key: key, function: "pg_advisory_lock") do
23
23
  # TODO: Why is `unscoped` necessary? Nested scope is bleeding into subsequent query?
24
24
  enqueue_concurrency = GoodJob::Job.unscoped.where(concurrency_key: key).unfinished.count
25
25
  # The job has not yet been enqueued, so check if adding it will go over the limit
26
- throw :abort if enqueue_concurrency + 1 > limit
26
+ block.call unless enqueue_concurrency + 1 > limit
27
27
  end
28
28
  end
29
29
 
@@ -41,9 +41,9 @@ module GoodJob
41
41
  next if key.blank?
42
42
 
43
43
  GoodJob::Job.new.with_advisory_lock(key: key, function: "pg_advisory_lock") do
44
- perform_concurrency = GoodJob::Job.unscoped.where(concurrency_key: key).advisory_locked.count
44
+ allowed_active_job_ids = GoodJob::Job.unscoped.where(concurrency_key: key).advisory_locked.order(Arel.sql("COALESCE(performed_at, scheduled_at, created_at) ASC")).limit(limit).pluck(:active_job_id)
45
45
  # The current job has already been locked and will appear in the previous query
46
- raise GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError if perform_concurrency > limit
46
+ raise GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError unless allowed_active_job_ids.include? job.job_id
47
47
  end
48
48
  end
49
49
  end
@@ -57,6 +57,8 @@ module GoodJob
57
57
  @scheduler = GoodJob::Scheduler.from_configuration(@configuration, warm_cache_on_initialize: Rails.application.initialized?)
58
58
  @notifier.recipients << [@scheduler, :create_thread]
59
59
  @poller.recipients << [@scheduler, :create_thread]
60
+
61
+ @cron_manager = GoodJob::CronManager.new(@configuration.cron, start_on_initialize: Rails.application.initialized?) if @configuration.enable_cron?
60
62
  end
61
63
  end
62
64
 
data/lib/good_job/cli.rb CHANGED
@@ -70,12 +70,16 @@ module GoodJob
70
70
  type: :numeric,
71
71
  banner: 'SECONDS',
72
72
  desc: "Number of seconds to wait for jobs to finish when shutting down before stopping the thread. (env var: GOOD_JOB_SHUTDOWN_TIMEOUT, default: -1 (forever))"
73
+ method_option :enable_cron,
74
+ type: :boolean,
75
+ desc: "Whether to run cron process (default: false)"
73
76
  method_option :daemonize,
74
77
  type: :boolean,
75
78
  desc: "Run as a background daemon (default: false)"
76
79
  method_option :pidfile,
77
80
  type: :string,
78
81
  desc: "Path to write daemonized Process ID (env var: GOOD_JOB_PIDFILE, default: tmp/pids/good_job.pid)"
82
+
79
83
  def start
80
84
  set_up_application!
81
85
  configuration = GoodJob::Configuration.new(options)
@@ -87,7 +91,7 @@ module GoodJob
87
91
  scheduler = GoodJob::Scheduler.from_configuration(configuration, warm_cache_on_initialize: true)
88
92
  notifier.recipients << [scheduler, :create_thread]
89
93
  poller.recipients << [scheduler, :create_thread]
90
-
94
+ cron_manager = GoodJob::CronManager.new(configuration.cron, start_on_initialize: true) if configuration.enable_cron?
91
95
  @stop_good_job_executable = false
92
96
  %w[INT TERM].each do |signal|
93
97
  trap(signal) { @stop_good_job_executable = true }
@@ -98,7 +102,7 @@ module GoodJob
98
102
  break if @stop_good_job_executable || scheduler.shutdown? || notifier.shutdown?
99
103
  end
100
104
 
101
- executors = [notifier, poller, scheduler]
105
+ executors = [notifier, poller, cron_manager, scheduler].compact
102
106
  GoodJob._shutdown_all(executors, timeout: configuration.shutdown_timeout)
103
107
  end
104
108
 
@@ -124,6 +128,7 @@ module GoodJob
124
128
  type: :numeric,
125
129
  banner: 'SECONDS',
126
130
  desc: "Delete records finished more than this many seconds ago (env var: GOOD_JOB_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO, default: 86400)"
131
+
127
132
  def cleanup_preserved_jobs
128
133
  set_up_application!
129
134
 
@@ -18,6 +18,8 @@ module GoodJob
18
18
  DEFAULT_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO = 24 * 60 * 60
19
19
  # Default to always wait for jobs to finish for {Adapter#shutdown}
20
20
  DEFAULT_SHUTDOWN_TIMEOUT = -1
21
+ # Default to not running cron
22
+ DEFAULT_ENABLE_CRON = false
21
23
 
22
24
  # The options that were explicitly set when initializing +Configuration+.
23
25
  # @return [Hash]
@@ -129,6 +131,28 @@ module GoodJob
129
131
  ).to_f
130
132
  end
131
133
 
134
+ # Whether to run cron
135
+ # @return [Boolean]
136
+ def enable_cron
137
+ value = ActiveModel::Type::Boolean.new.cast(
138
+ options[:enable_cron] ||
139
+ rails_config[:enable_cron] ||
140
+ env['GOOD_JOB_ENABLE_CRON'] ||
141
+ false
142
+ )
143
+ value && cron.size.positive?
144
+ end
145
+ alias enable_cron? enable_cron
146
+
147
+ def cron
148
+ env_cron = JSON.parse(ENV['GOOD_JOB_CRON']) if ENV['GOOD_JOB_CRON'].present?
149
+
150
+ options[:cron] ||
151
+ rails_config[:cron] ||
152
+ env_cron ||
153
+ {}
154
+ end
155
+
132
156
  # Number of seconds to preserve jobs when using the +good_job cleanup_preserved_jobs+ CLI command.
133
157
  # This configuration is only used when {GoodJob.preserve_job_records} is +true+.
134
158
  # @return [Integer]
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+ require "concurrent/hash"
3
+ require "concurrent/scheduled_task"
4
+ require "fugit"
5
+
6
+ module GoodJob # :nodoc:
7
+ #
8
+ # CronManagers enqueue jobs on a repeating schedule.
9
+ #
10
+ class CronManager
11
+ # @!attribute [r] instances
12
+ # @!scope class
13
+ # List of all instantiated CronManagers in the current process.
14
+ # @return [Array<GoodJob::CronManagers>, nil]
15
+ cattr_reader :instances, default: [], instance_reader: false
16
+
17
+ # Task observer for cron task
18
+ # @param time [Time]
19
+ # @param output [Object]
20
+ # @param thread_error [Exception]
21
+ def self.task_observer(time, output, thread_error) # rubocop:disable Lint/UnusedMethodArgument
22
+ return if thread_error.is_a? Concurrent::CancelledOperationError
23
+
24
+ GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
25
+ end
26
+
27
+ # Job configuration to be scheduled
28
+ # @return [Hash]
29
+ attr_reader :schedules
30
+
31
+ # @param schedules [Hash]
32
+ # @param start_on_initialize [Boolean]
33
+ def initialize(schedules = {}, start_on_initialize: false)
34
+ @running = false
35
+ @schedules = schedules
36
+ @tasks = Concurrent::Hash.new
37
+
38
+ self.class.instances << self
39
+
40
+ start if start_on_initialize
41
+ end
42
+
43
+ # Schedule tasks that will enqueue jobs based on their schedule
44
+ def start
45
+ ActiveSupport::Notifications.instrument("cron_manager_start.good_job", cron_jobs: @schedules) do
46
+ @running = true
47
+ schedules.each_key { |cron_key| create_task(cron_key) }
48
+ end
49
+ end
50
+
51
+ # Stop/cancel any scheduled tasks
52
+ # @param timeout [Numeric, nil] Unused but retained for compatibility
53
+ def shutdown(timeout: nil) # rubocop:disable Lint/UnusedMethodArgument
54
+ @running = false
55
+ @tasks.each do |_cron_key, task|
56
+ task.cancel
57
+ end
58
+ @tasks.clear
59
+ end
60
+
61
+ # Stop and restart
62
+ # @param timeout [Numeric, nil] Unused but retained for compatibility
63
+ def restart(timeout: nil) # rubocop:disable Lint/UnusedMethodArgument
64
+ shutdown
65
+ start
66
+ end
67
+
68
+ # Tests whether the manager is running.
69
+ # @return [Boolean, nil]
70
+ def running?
71
+ @running
72
+ end
73
+
74
+ # Tests whether the manager is shutdown.
75
+ # @return [Boolean, nil]
76
+ def shutdown?
77
+ !running?
78
+ end
79
+
80
+ # Enqueues a scheduled task
81
+ # @param cron_key [Symbol, String] the key within the schedule to use
82
+ def create_task(cron_key)
83
+ schedule = @schedules[cron_key]
84
+ return false if schedule.blank?
85
+
86
+ fugit = Fugit::Cron.parse(schedule.fetch(:cron))
87
+ delay = [(fugit.next_time - Time.current).to_f, 0].max
88
+
89
+ future = Concurrent::ScheduledTask.new(delay, args: [self, cron_key]) do |thr_scheduler, thr_cron_key|
90
+ # Re-schedule the next cron task before executing the current task
91
+ thr_scheduler.create_task(thr_cron_key)
92
+
93
+ CurrentExecution.reset
94
+ CurrentExecution.cron_key = thr_cron_key
95
+
96
+ Rails.application.executor.wrap do
97
+ schedule = thr_scheduler.schedules.fetch(thr_cron_key).with_indifferent_access
98
+ job_class = schedule.fetch(:class).constantize
99
+
100
+ job_set_value = schedule.fetch(:set, {})
101
+ job_set = job_set_value.respond_to?(:call) ? job_set_value.call : job_set_value
102
+
103
+ job_args_value = schedule.fetch(:args, [])
104
+ job_args = job_args_value.respond_to?(:call) ? job_args_value.call : job_args_value
105
+
106
+ job_class.set(job_set).perform_later(*job_args)
107
+ end
108
+ end
109
+
110
+ @tasks[cron_key] = future
111
+ future.add_observer(self.class, :task_observer)
112
+ future.execute
113
+ end
114
+ end
115
+ end
@@ -5,11 +5,11 @@ module GoodJob
5
5
  # Thread-local attributes for passing values from Instrumentation.
6
6
  # (Cannot use ActiveSupport::CurrentAttributes because ActiveJob resets it)
7
7
  module CurrentExecution
8
- # @!attribute [rw] active_job_id
8
+ # @!attribute [rw] cron_key
9
9
  # @!scope class
10
- # ActiveJob ID
10
+ # Cron Key
11
11
  # @return [String, nil]
12
- thread_mattr_accessor :active_job_id
12
+ thread_mattr_accessor :cron_key
13
13
 
14
14
  # @!attribute [rw] error_on_discard
15
15
  # @!scope class
@@ -23,14 +23,26 @@ module GoodJob
23
23
  # @return [Exception, nil]
24
24
  thread_mattr_accessor :error_on_retry
25
25
 
26
+ # @!attribute [rw] good_job
27
+ # @!scope class
28
+ # Cron Key
29
+ # @return [GoodJob::Job, nil]
30
+ thread_mattr_accessor :good_job
31
+
26
32
  # Resets attributes
27
33
  # @return [void]
28
34
  def self.reset
29
- self.active_job_id = nil
35
+ self.cron_key = nil
36
+ self.good_job = nil
30
37
  self.error_on_discard = nil
31
38
  self.error_on_retry = nil
32
39
  end
33
40
 
41
+ # @return [String] UUID of the currently executing GoodJob::Job
42
+ def self.active_job_id
43
+ good_job&.active_job_id
44
+ end
45
+
34
46
  # @return [Integer] Current process ID
35
47
  def self.process_id
36
48
  Process.pid
data/lib/good_job/job.rb CHANGED
@@ -52,6 +52,20 @@ module GoodJob
52
52
  end
53
53
  end
54
54
 
55
+ def self._migration_pending_warning
56
+ ActiveSupport::Deprecation.warn(<<~DEPRECATION)
57
+ GoodJob has pending database migrations. To create the migration files, run:
58
+
59
+ rails generate good_job:update
60
+
61
+ To apply the migration files, run:
62
+
63
+ rails db:migrate
64
+
65
+ DEPRECATION
66
+ nil
67
+ end
68
+
55
69
  # Get Jobs with given class name
56
70
  # @!method with_job_class
57
71
  # @!scope class
@@ -110,6 +124,18 @@ module GoodJob
110
124
  # @return [ActiveRecord::Relation]
111
125
  scope :running, -> { where.not(performed_at: nil).where(finished_at: nil) }
112
126
 
127
+ # Get Jobs that do not have subsequent retries
128
+ # @!method running
129
+ # @!scope class
130
+ # @return [ActiveRecord::Relation]
131
+ scope :head, -> { where(retried_good_job_id: nil) }
132
+
133
+ # Get Jobs have errored that will not be retried further
134
+ # @!method running
135
+ # @!scope class
136
+ # @return [ActiveRecord::Relation]
137
+ scope :dead, -> { head.where.not(error: nil) }
138
+
113
139
  # Get Jobs on queues that match the given queue string.
114
140
  # @!method queue_string(string)
115
141
  # @!scope class
@@ -209,31 +235,23 @@ module GoodJob
209
235
  if column_names.include?('active_job_id')
210
236
  good_job_args[:active_job_id] = active_job.job_id
211
237
  else
212
- ActiveSupport::Deprecation.warn(<<~DEPRECATION)
213
- GoodJob has pending database migrations. To create the migration files, run:
214
-
215
- rails generate good_job:update
216
-
217
- To apply the migration files, run:
218
-
219
- rails db:migrate
220
-
221
- DEPRECATION
238
+ _migration_pending_warning
222
239
  end
223
240
 
224
241
  if column_names.include?('concurrency_key')
225
242
  good_job_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
226
243
  else
227
- ActiveSupport::Deprecation.warn(<<~DEPRECATION)
228
- GoodJob has pending database migrations. To create the migration files, run:
229
-
230
- rails generate good_job:update
231
-
232
- To apply the migration files, run:
233
-
234
- rails db:migrate
244
+ _migration_pending_warning
245
+ end
235
246
 
236
- DEPRECATION
247
+ if column_names.include?('cron_key')
248
+ if CurrentExecution.cron_key
249
+ good_job_args[:cron_key] = CurrentExecution.cron_key
250
+ elsif CurrentExecution.active_job_id == active_job.job_id
251
+ good_job_args[:cron_key] = CurrentExecution.good_job.cron_key
252
+ end
253
+ else
254
+ _migration_pending_warning
237
255
  end
238
256
 
239
257
  good_job = GoodJob::Job.new(**good_job_args)
@@ -243,6 +261,12 @@ module GoodJob
243
261
  good_job.save!
244
262
  active_job.provider_job_id = good_job.id
245
263
 
264
+ if column_names.include?('retried_good_job_id')
265
+ CurrentExecution.good_job.retried_good_job_id = good_job.id if CurrentExecution.good_job && CurrentExecution.good_job.active_job_id == active_job.job_id
266
+ else
267
+ _migration_pending_warning
268
+ end
269
+
246
270
  good_job
247
271
  end
248
272
  end
@@ -282,7 +306,21 @@ module GoodJob
282
306
  end
283
307
 
284
308
  def active_job_id
285
- super || serialized_params['job_id']
309
+ if self.class.column_names.include?('active_job_id')
310
+ super
311
+ else
312
+ self.class._migration_pending_warning
313
+ serialized_params['job_id']
314
+ end
315
+ end
316
+
317
+ def cron_key
318
+ if self.class.column_names.include?('cron_key')
319
+ super
320
+ else
321
+ self.class._migration_pending_warning
322
+ nil
323
+ end
286
324
  end
287
325
 
288
326
  private
@@ -294,7 +332,7 @@ module GoodJob
294
332
  )
295
333
 
296
334
  GoodJob::CurrentExecution.reset
297
- GoodJob::CurrentExecution.active_job_id = active_job_id
335
+ GoodJob::CurrentExecution.good_job = self
298
336
  ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, process_id: GoodJob::CurrentExecution.process_id, thread_name: GoodJob::CurrentExecution.thread_name }) do
299
337
  value = ActiveJob::Base.execute(params)
300
338
 
@@ -57,6 +57,16 @@ module GoodJob
57
57
  end
58
58
  end
59
59
 
60
+ # @!macro notification_responder
61
+ def cron_manager_start(event)
62
+ cron_jobs = event.payload[:cron_jobs]
63
+ cron_jobs_count = cron_jobs.size
64
+
65
+ info do
66
+ "GoodJob started cron with #{cron_jobs_count} #{'jobs'.pluralize(cron_jobs_count)}."
67
+ end
68
+ end
69
+
60
70
  # @!macro notification_responder
61
71
  def scheduler_shutdown_start(event)
62
72
  process_id = event.payload[:process_id]
@@ -3,6 +3,7 @@ module GoodJob
3
3
  # Ruby on Rails integration.
4
4
  class Railtie < ::Rails::Railtie
5
5
  config.good_job = ActiveSupport::OrderedOptions.new
6
+ config.good_job.cron = {}
6
7
 
7
8
  initializer "good_job.logger" do |_app|
8
9
  ActiveSupport.on_load(:good_job) do
@@ -23,6 +24,7 @@ module GoodJob
23
24
 
24
25
  config.after_initialize do
25
26
  GoodJob::Scheduler.instances.each(&:warm_cache)
27
+ GoodJob::CronManager.instances.each(&:start)
26
28
  end
27
29
  end
28
30
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  module GoodJob
3
3
  # GoodJob gem version.
4
- VERSION = '1.11.3'
4
+ VERSION = '1.13.0'
5
5
  end
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.11.3
4
+ version: 1.13.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: 2021-07-25 00:00:00.000000000 Z
11
+ date: 2021-08-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: 1.0.2
55
+ - !ruby/object:Gem::Dependency
56
+ name: fugit
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '1.1'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '1.1'
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: railties
57
71
  requirement: !ruby/object:Gem::Requirement
@@ -345,12 +359,12 @@ files:
345
359
  - engine/app/helpers/good_job/application_helper.rb
346
360
  - engine/app/views/good_job/active_jobs/show.html.erb
347
361
  - engine/app/views/good_job/dashboards/index.html.erb
362
+ - engine/app/views/good_job/shared/_chart.erb
363
+ - engine/app/views/good_job/shared/_jobs_table.erb
364
+ - engine/app/views/good_job/shared/icons/_check.html.erb
365
+ - engine/app/views/good_job/shared/icons/_exclamation.html.erb
366
+ - engine/app/views/good_job/shared/icons/_trash.html.erb
348
367
  - engine/app/views/layouts/good_job/base.html.erb
349
- - engine/app/views/shared/_chart.erb
350
- - engine/app/views/shared/_jobs_table.erb
351
- - engine/app/views/shared/icons/_check.html.erb
352
- - engine/app/views/shared/icons/_exclamation.html.erb
353
- - engine/app/views/shared/icons/_trash.html.erb
354
368
  - engine/config/routes.rb
355
369
  - engine/lib/good_job/engine.rb
356
370
  - exe/good_job
@@ -360,6 +374,7 @@ files:
360
374
  - lib/generators/good_job/templates/update/migrations/01_create_good_jobs.rb
361
375
  - lib/generators/good_job/templates/update/migrations/02_add_active_job_id_concurrency_key_cron_key_to_good_jobs.rb
362
376
  - lib/generators/good_job/templates/update/migrations/03_add_active_job_id_index_and_concurrency_key_index_to_good_jobs.rb
377
+ - lib/generators/good_job/templates/update/migrations/04_add_retried_good_job_id_to_good_jobs.rb
363
378
  - lib/generators/good_job/update_generator.rb
364
379
  - lib/good_job.rb
365
380
  - lib/good_job/active_job_extensions.rb
@@ -367,6 +382,7 @@ files:
367
382
  - lib/good_job/adapter.rb
368
383
  - lib/good_job/cli.rb
369
384
  - lib/good_job/configuration.rb
385
+ - lib/good_job/cron_manager.rb
370
386
  - lib/good_job/current_execution.rb
371
387
  - lib/good_job/daemon.rb
372
388
  - lib/good_job/execution_result.rb