good_job 2.4.2 → 2.5.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: f06b525450f56cf74ff31e06e11cbf3bf2ee7f20d753973e70ddb53d67eb48d1
4
- data.tar.gz: d25d2037ae03eaea0ff8bd4ca1dfa52ba6a57d3420e53b4be265dc7c0be580cb
3
+ metadata.gz: 5a173683ec5e5879728536005c7dfd11827ffe87c268c59315f540911277f2df
4
+ data.tar.gz: a5990d902838da25344ff96fc859bad39803f70f888dd95de8e3e6c201da4b0f
5
5
  SHA512:
6
- metadata.gz: 2f2be221d400f0dc7a1e7c2d262b717ecd9c88a5192771df2bd343d61e878cb777af7e067814e3e7c751aa28716c2bd487b4389ef4dbbea17cc2a6a0f23d0c9a
7
- data.tar.gz: abfd020a2203bb573c5e801e7f278214ebe996b6e63970ec60313fcb37a3b21cb901d4cf57cfd6739b7c247ea692ff7de012df3668d7c551d7ced78cc48b79e6
6
+ metadata.gz: 92378343ecf6f3750a98ac1e146c748e7440b267fad30ac04eaf984649e5640fad8cfdd58b9c627bb7f7792202a849222ee7ed7583b7ac847f2d15839c25519f
7
+ data.tar.gz: f093bda085b00d82210e9bcf02ed6e39fec556aeeaa17c6f73a9465119867833f3145905482a20ec57840ae6bb6fa350f79e64209425cef8db9f45c0b59c4ea0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [v2.5.0](https://github.com/bensheldon/good_job/tree/v2.5.0) (2021-10-25)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v2.4.2...v2.5.0)
6
+
7
+ **Implemented enhancements:**
8
+
9
+ - Add Reschedule, Discard, Retry Job buttons to Dashboard [\#425](https://github.com/bensheldon/good_job/pull/425) ([bensheldon](https://github.com/bensheldon))
10
+ - Use unique index on \[cron\_key, cron\_at\] columns to prevent duplicate cron jobs from being enqueued [\#423](https://github.com/bensheldon/good_job/pull/423) ([bensheldon](https://github.com/bensheldon))
11
+
12
+ **Fixed bugs:**
13
+
14
+ - Dashboard fix preservation of `limit` and `queue_name` filter params; add pager to jobs [\#434](https://github.com/bensheldon/good_job/pull/434) ([bensheldon](https://github.com/bensheldon))
15
+
16
+ **Closed issues:**
17
+
18
+ - PgLock state inspection is not isolated to current database [\#431](https://github.com/bensheldon/good_job/issues/431)
19
+ - Race condition with concurency control [\#378](https://github.com/bensheldon/good_job/issues/378)
20
+
21
+ **Merged pull requests:**
22
+
23
+ - Add Readme note about race conditions in Concurrency's `enqueue\_limit` and `perform\_limit [\#433](https://github.com/bensheldon/good_job/pull/433) ([bensheldon](https://github.com/bensheldon))
24
+ - Test harness should only force-unlock db connections for the current database [\#430](https://github.com/bensheldon/good_job/pull/430) ([bensheldon](https://github.com/bensheldon))
25
+
3
26
  ## [v2.4.2](https://github.com/bensheldon/good_job/tree/v2.4.2) (2021-10-19)
4
27
 
5
28
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v2.4.1...v2.4.2)
data/README.md CHANGED
@@ -363,11 +363,19 @@ class MyJob < ApplicationJob
363
363
  total_limit: 1,
364
364
 
365
365
  # Or, if more control is needed:
366
- # Maximum number of jobs with the concurrency key to be concurrently enqueued (excludes performing jobs)
366
+ # Maximum number of jobs with the concurrency key to be
367
+ # concurrently enqueued (excludes performing jobs)
367
368
  enqueue_limit: 2,
368
- # Maximum number of jobs with the concurrency key to be concurrently performed (excludes enqueued jobs)
369
+
370
+ # Maximum number of jobs with the concurrency key to be
371
+ # concurrently performed (excludes enqueued jobs)
369
372
  perform_limit: 1,
370
373
 
374
+ # Note: Under heavy load, the total number of jobs may exceed the
375
+ # sum of `enqueue_limit` and `perform_limit` because of race conditions
376
+ # caused by imperfectly disjunctive states. If you need to constrain
377
+ # the total number of jobs, use `total_limit` instead. See #378.
378
+
371
379
  # A unique key to be globally locked against.
372
380
  # Can be String or Lambda/Proc that is invoked in the context of the job.
373
381
  # Note: Arguments passed to #perform_later must be accessed through `arguments` method.
@@ -391,7 +399,7 @@ job.good_job_concurrency_key #=> "Unique-Alice"
391
399
 
392
400
  GoodJob can enqueue jobs on a recurring basis that can be used as a replacement for cron.
393
401
 
394
- 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.
402
+ Cron-style jobs are run on every GoodJob process (e.g. CLI or `async` execution mode) when `config.good_job.enable_cron = true`, but GoodJob's cron uses unique indexes to ensure that only a single job is enqeued at the given time interval.
395
403
 
396
404
  Cron-format is parsed by the [`fugit`](https://github.com/floraison/fugit) gem, which has support for seconds-level resolution (e.g. `* * * * * *`).
397
405
 
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
  module GoodJob
3
3
  class JobsController < GoodJob::BaseController
4
+ rescue_from GoodJob::ActiveJobJob::AdapterNotGoodJobError,
5
+ GoodJob::ActiveJobJob::ActionForStateMismatchError,
6
+ with: :redirect_on_error
7
+
4
8
  def index
5
9
  @filter = JobsFilter.new(params)
6
10
  end
@@ -10,5 +14,37 @@ module GoodJob
10
14
  .order(Arel.sql("COALESCE(scheduled_at, created_at) DESC"))
11
15
  redirect_to root_path, alert: "Executions for Active Job #{params[:id]} not found" if @executions.empty?
12
16
  end
17
+
18
+ def discard
19
+ @job = ActiveJobJob.find(params[:id])
20
+ @job.discard_job("Discarded through dashboard")
21
+ redirect_back(fallback_location: jobs_path, notice: "Job has been discarded")
22
+ end
23
+
24
+ def reschedule
25
+ @job = ActiveJobJob.find(params[:id])
26
+ @job.reschedule_job
27
+ redirect_back(fallback_location: jobs_path, notice: "Job has been rescheduled")
28
+ end
29
+
30
+ def retry
31
+ @job = ActiveJobJob.find(params[:id])
32
+ @job.retry_job
33
+ redirect_back(fallback_location: jobs_path, notice: "Job has been retried")
34
+ end
35
+
36
+ private
37
+
38
+ def redirect_on_error(exception)
39
+ alert = case exception
40
+ when GoodJob::ActiveJobJob::AdapterNotGoodJobError
41
+ "ActiveJob Queue Adapter must be GoodJob."
42
+ when GoodJob::ActiveJobJob::ActionForStateMismatchError
43
+ "Job is not in an appropriate state for this action."
44
+ else
45
+ exception.to_s
46
+ end
47
+ redirect_back(fallback_location: jobs_path, alert: alert)
48
+ end
13
49
  end
14
50
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  module GoodJob
3
3
  class BaseFilter
4
+ DEFAULT_LIMIT = 25
5
+
4
6
  attr_accessor :params
5
7
 
6
8
  def initialize(params)
@@ -13,7 +15,7 @@ module GoodJob
13
15
  filtered_query.display_all(
14
16
  after_scheduled_at: after_scheduled_at,
15
17
  after_id: params[:after_id]
16
- ).limit(params.fetch(:limit, 25))
18
+ ).limit(params.fetch(:limit, DEFAULT_LIMIT))
17
19
  end
18
20
 
19
21
  def last
@@ -38,8 +40,10 @@ module GoodJob
38
40
 
39
41
  def to_params(override)
40
42
  {
41
- state: params[:state],
42
43
  job_class: params[:job_class],
44
+ limit: params[:limit],
45
+ queue_name: params[:queue_name],
46
+ state: params[:state],
43
47
  }.merge(override).delete_if { |_, v| v.nil? }
44
48
  end
45
49
 
@@ -19,7 +19,9 @@ module GoodJob
19
19
  end
20
20
 
21
21
  def filtered_query
22
- query = base_query
22
+ query = base_query.includes(:executions)
23
+ .joins_advisory_locks.select('good_jobs.*', 'pg_locks.locktype AS locktype')
24
+
23
25
  query = query.job_class(params[:job_class]) if params[:job_class]
24
26
  query = query.where(queue_name: params[:queue_name]) if params[:queue_name]
25
27
 
@@ -1,13 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
  module GoodJob
3
3
  # ActiveRecord model that represents an +ActiveJob+ job.
4
- # Is the same record data as a {GoodJob::Execution} but only the most recent execution.
4
+ # There is not a table in the database whose discrete rows represents "Jobs".
5
+ # The +good_jobs+ table is a table of individual {GoodJob::Execution}s that share the same +active_job_id+.
6
+ # A single row from the +good_jobs+ table of executions is fetched to represent an ActiveJobJob
5
7
  # Parent class can be configured with +GoodJob.active_record_parent_class+.
6
8
  # @!parse
7
9
  # class ActiveJob < ActiveRecord::Base; end
8
10
  class ActiveJobJob < Object.const_get(GoodJob.active_record_parent_class)
9
11
  include GoodJob::Lockable
10
12
 
13
+ # Raised when an inappropriate action is applied to a Job based on its state.
14
+ ActionForStateMismatchError = Class.new(StandardError)
15
+ # Raised when an action requires GoodJob to be the ActiveJob Queue Adapter but GoodJob is not.
16
+ AdapterNotGoodJobError = Class.new(StandardError)
17
+ # Attached to a Job's Execution when the Job is discarded.
18
+ DiscardJobError = Class.new(StandardError)
19
+
11
20
  self.table_name = 'good_jobs'
12
21
  self.primary_key = 'active_job_id'
13
22
  self.advisory_lockable_column = 'active_job_id'
@@ -56,27 +65,41 @@ module GoodJob
56
65
  query
57
66
  end)
58
67
 
68
+ # The job's ActiveJob UUID
69
+ # @return [String]
59
70
  def id
60
71
  active_job_id
61
72
  end
62
73
 
63
- def _execution_id
64
- attributes['id']
65
- end
66
-
74
+ # The ActiveJob job class, as a string
75
+ # @return [String]
67
76
  def job_class
68
77
  serialized_params['job_class']
69
78
  end
70
79
 
80
+ # The status of the Job, based on the state of its most recent execution.
81
+ # There are 3 buckets of non-overlapping statuses:
82
+ # 1. The job will be executed
83
+ # - queued: The job will execute immediately when an execution thread becomes available.
84
+ # - scheduled: The job is scheduled to execute in the future.
85
+ # - retried: The job previously errored on execution and will be re-executed in the future.
86
+ # 2. The job is being executed
87
+ # - running: the job is actively being executed by an execution thread
88
+ # 3. The job will not execute
89
+ # - finished: The job executed successfully
90
+ # - discarded: The job previously errored on execution and will not be re-executed in the future.
91
+ #
92
+ # @return [Symbol]
71
93
  def status
72
- if finished_at.present?
73
- if error.present?
94
+ execution = head_execution
95
+ if execution.finished_at.present?
96
+ if execution.error.present?
74
97
  :discarded
75
98
  else
76
99
  :finished
77
100
  end
78
- elsif (scheduled_at || created_at) > DateTime.current
79
- if serialized_params.fetch('executions', 0) > 1
101
+ elsif (execution.scheduled_at || execution.created_at) > DateTime.current
102
+ if execution.serialized_params.fetch('executions', 0) > 1
80
103
  :retried
81
104
  else
82
105
  :scheduled
@@ -88,16 +111,25 @@ module GoodJob
88
111
  end
89
112
  end
90
113
 
91
- def head_execution
114
+ # This job's most recent {Execution}
115
+ # @param reload [Booelan] whether to reload executions
116
+ # @return [Execution]
117
+ def head_execution(reload: false)
118
+ executions.reload if reload
119
+ executions.load # memoize the results
92
120
  executions.last
93
121
  end
94
122
 
123
+ # This job's initial/oldest {Execution}
124
+ # @return [Execution]
95
125
  def tail_execution
96
126
  executions.first
97
127
  end
98
128
 
129
+ # The number of times this job has been executed, according to ActiveJob's serialized state.
130
+ # @return [Numeric]
99
131
  def executions_count
100
- aj_count = serialized_params.fetch('executions', 0)
132
+ aj_count = head_execution.serialized_params.fetch('executions', 0)
101
133
  # The execution count within serialized_params is not updated
102
134
  # once the underlying execution has been executed.
103
135
  if status.in? [:discarded, :finished, :running]
@@ -107,14 +139,21 @@ module GoodJob
107
139
  end
108
140
  end
109
141
 
142
+ # The number of times this job has been executed, according to the number of GoodJob {Execution} records.
143
+ # @return [Numeric]
110
144
  def preserved_executions_count
111
145
  executions.size
112
146
  end
113
147
 
148
+ # The most recent error message.
149
+ # If the job has been retried, the error will be fetched from the previous {Execution} record.
150
+ # @return [String]
114
151
  def recent_error
115
- error.presence || executions[-2]&.error
152
+ head_execution.error || executions[-2]&.error
116
153
  end
117
154
 
155
+ # Tests whether the job is being executed right now.
156
+ # @return [Boolean]
118
157
  def running?
119
158
  # Avoid N+1 Query: `.joins_advisory_locks.select('good_jobs.*', 'pg_locks.locktype AS locktype')`
120
159
  if has_attribute?(:locktype)
@@ -123,5 +162,84 @@ module GoodJob
123
162
  advisory_locked?
124
163
  end
125
164
  end
165
+
166
+ # Retry a job that has errored and been discarded.
167
+ # This action will create a new job {Execution} record.
168
+ # @return [ActiveJob::Base]
169
+ def retry_job
170
+ with_advisory_lock do
171
+ execution = head_execution(reload: true)
172
+ active_job = execution.active_job
173
+
174
+ raise AdapterNotGoodJobError unless active_job.class.queue_adapter.is_a? GoodJob::Adapter
175
+ raise ActionForStateMismatchError unless status == :discarded
176
+
177
+ # Update the executions count because the previous execution will not have been preserved
178
+ # Do not update `exception_executions` because that comes from rescue_from's arguments
179
+ active_job.executions = (active_job.executions || 0) + 1
180
+
181
+ new_active_job = nil
182
+ GoodJob::CurrentThread.within do |current_thread|
183
+ current_thread.execution = execution
184
+
185
+ execution.class.transaction(joinable: false, requires_new: true) do
186
+ new_active_job = active_job.retry_job(wait: 0, error: error)
187
+ execution.save
188
+ end
189
+ end
190
+ new_active_job
191
+ end
192
+ end
193
+
194
+ # Discard a job so that it will not be executed further.
195
+ # This action will add a {DiscardJobError} to the job's {Execution} and mark it as finished.
196
+ # @return [void]
197
+ def discard_job(message)
198
+ with_advisory_lock do
199
+ raise ActionForStateMismatchError unless status.in? [:scheduled, :queued, :retried]
200
+
201
+ execution = head_execution(reload: true)
202
+ active_job = execution.active_job
203
+
204
+ job_error = GoodJob::ActiveJobJob::DiscardJobError.new(message)
205
+
206
+ update_execution = proc do
207
+ execution.update(
208
+ finished_at: Time.current,
209
+ error: [job_error.class, GoodJob::Execution::ERROR_MESSAGE_SEPARATOR, job_error.message].join
210
+ )
211
+ end
212
+
213
+ if active_job.respond_to?(:instrument)
214
+ active_job.send :instrument, :discard, error: job_error, &update_execution
215
+ else
216
+ update_execution.call
217
+ end
218
+ end
219
+ end
220
+
221
+ # Reschedule a scheduled job so that it executes immediately (or later) by the next available execution thread.
222
+ # @param scheduled_at [DateTime, Time] When to reschedule the job
223
+ # @return [void]
224
+ def reschedule_job(scheduled_at = Time.current)
225
+ with_advisory_lock do
226
+ raise ActionForStateMismatchError unless status.in? [:scheduled, :queued, :retried]
227
+
228
+ execution = head_execution(reload: true)
229
+ execution.update(scheduled_at: scheduled_at)
230
+ end
231
+ end
232
+
233
+ # Utility method to determine which execution record is used to represent this job
234
+ # @return [String]
235
+ def _execution_id
236
+ attributes['id']
237
+ end
238
+
239
+ # Utility method to test whether this job's underlying attributes represents its most recent execution.
240
+ # @return [Boolean]
241
+ def _head?
242
+ _execution_id == head_execution(reload: true).id
243
+ end
126
244
  end
127
245
  end
@@ -60,7 +60,7 @@
60
60
  </td>
61
61
  <td class="font-monospace"><%= cron_entry.job_class %></td>
62
62
  <td><%= cron_entry.description %></td>
63
- <td><%= cron_entry.next_at.to_local_time %></td>
63
+ <td><%= cron_entry.next_at %></td>
64
64
  </tr>
65
65
  <% end %>
66
66
  </tbody>
@@ -4,4 +4,17 @@
4
4
 
5
5
  <%= render 'good_job/shared/filter', filter: @filter %>
6
6
 
7
- <%= render 'good_job/shared/jobs_table', jobs: @filter.records %>
7
+ <% if @filter.records.present? %>
8
+ <%= render 'good_job/shared/jobs_table', jobs: @filter.records %>
9
+ <nav aria-label="Job pagination" class="mt-3">
10
+ <ul class="pagination">
11
+ <li class="page-item">
12
+ <%= link_to(@filter.to_params(after_scheduled_at: (@filter.last.scheduled_at || @filter.last.created_at), after_id: @filter.last.id), class: "page-link") do %>
13
+ Older jobs <span aria-hidden="true">&raquo;</span>
14
+ <% end %>
15
+ </li>
16
+ </ul>
17
+ </nav>
18
+ <% else %>
19
+ <em>No jobs present.</em>
20
+ <% end %>
@@ -43,11 +43,23 @@
43
43
  %>
44
44
  <%= tag.pre JSON.pretty_generate(job.serialized_params), id: dom_id(job, "params"), class: "collapse job-params" %>
45
45
  </td>
46
- <!-- <td>-->
47
- <%#= button_to execution_path(execution.id), method: :delete, class: "btn btn-sm btn-outline-danger", title: "Delete execution" do %>
48
- <%#= render "good_job/shared/icons/trash" %>
49
- <%# end %>
50
- <!-- </td>-->
46
+ <td>
47
+ <div class="text-nowrap">
48
+ <% job_reschedulable = job.status.in? [:scheduled, :retried, :queued] %>
49
+ <%= button_to reschedule_job_path(job.id), method: :put, class: "btn btn-sm #{job_reschedulable ? 'btn-outline-primary' : 'btn-outline-secondary'}", form_class: "d-inline-block", disabled: !job_reschedulable, aria: { label: "Reschedule job" }, title: "Reschedule job" do %>
50
+ <%= render "good_job/shared/icons/skip_forward" %>
51
+ <% end %>
52
+
53
+ <% job_discardable = job.status.in? [:scheduled, :retried, :queued] %>
54
+ <%= button_to discard_job_path(job.id), method: :put, class: "btn btn-sm #{job_discardable ? 'btn-outline-primary' : 'btn-outline-secondary'}", form_class: "d-inline-block", disabled: !job_discardable, aria: { label: "Discard job" }, title: "Discard job" do %>
55
+ <%= render "good_job/shared/icons/stop" %>
56
+ <% end %>
57
+
58
+ <%= button_to retry_job_path(job.id), method: :put, class: "btn btn-sm #{job.status == :discarded ? 'btn-outline-primary' : 'btn-outline-secondary'}", form_class: "d-inline-block", disabled: job.status != :discarded, aria: { label: "Retry job" }, title: "Retry job" do %>
59
+ <%= render "good_job/shared/icons/arrow_clockwise" %>
60
+ <% end %>
61
+ </div>
62
+ </td>
51
63
  </tr>
52
64
  <% end %>
53
65
  </tbody>
@@ -0,0 +1,5 @@
1
+ <!-- https://icons.getbootstrap.com/icons/arrow-clockwise/ -->
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16">
3
+ <path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z" />
4
+ <path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z" />
5
+ </svg>
@@ -0,0 +1,4 @@
1
+ <!-- https://icons.getbootstrap.com/icons/skip-forward/ -->
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-skip-forward" viewBox="0 0 16 16">
3
+ <path d="M15.5 3.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V8.752l-6.267 3.636c-.52.302-1.233-.043-1.233-.696v-2.94l-6.267 3.636C.713 12.69 0 12.345 0 11.692V4.308c0-.653.713-.998 1.233-.696L7.5 7.248v-2.94c0-.653.713-.998 1.233-.696L15 7.248V4a.5.5 0 0 1 .5-.5zM1 4.633v6.734L6.804 8 1 4.633zm7.5 0v6.734L14.304 8 8.5 4.633z" />
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ <!-- https://icons.getbootstrap.com/icons/stop/ -->
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-stop" viewBox="0 0 16 16">
3
+ <path d="M3.5 5A1.5 1.5 0 0 1 5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11V5zM5 4.5a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5H5z" />
4
+ </svg>
@@ -2,7 +2,13 @@
2
2
  GoodJob::Engine.routes.draw do
3
3
  root to: 'executions#index'
4
4
  resources :cron_schedules, only: %i[index]
5
- resources :jobs, only: %i[index show]
5
+ resources :jobs, only: %i[index show] do
6
+ member do
7
+ put :discard
8
+ put :reschedule
9
+ put :retry
10
+ end
11
+ end
6
12
  resources :executions, only: %i[destroy]
7
13
 
8
14
  scope controller: :assets do
@@ -18,6 +18,7 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
18
18
  t.text :concurrency_key
19
19
  t.text :cron_key
20
20
  t.uuid :retried_good_job_id
21
+ t.timestamp :cron_at
21
22
  end
22
23
 
23
24
  add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: "index_good_jobs_on_scheduled_at"
@@ -25,5 +26,6 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
25
26
  add_index :good_jobs, [:active_job_id, :created_at], name: :index_good_jobs_on_active_job_id_and_created_at
26
27
  add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished
27
28
  add_index :good_jobs, [:cron_key, :created_at], name: :index_good_jobs_on_cron_key_and_created_at
29
+ add_index :good_jobs, [:cron_key, :cron_at], name: :index_good_jobs_on_cron_key_and_cron_at, unique: true
28
30
  end
29
31
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ class AddCronAtToGoodJobs < ActiveRecord::Migration<%= migration_version %>
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, :cron_at)
9
+ end
10
+ end
11
+
12
+ add_column :good_jobs, :cron_at, :timestamp
13
+ end
14
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ class AddCronKeyCronAtIndexToGoodJobs < ActiveRecord::Migration<%= migration_version %>
3
+ disable_ddl_transaction!
4
+
5
+ def change
6
+ reversible do |dir|
7
+ dir.up do
8
+ # Ensure this incremental update migration is idempotent
9
+ # with monolithic install migration.
10
+ return if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at)
11
+ end
12
+ end
13
+
14
+ add_index :good_jobs,
15
+ [:cron_key, :cron_at],
16
+ algorithm: :concurrently,
17
+ name: :index_good_jobs_on_cron_key_and_cron_at,
18
+ unique: true
19
+ end
20
+ end
@@ -43,11 +43,13 @@ module GoodJob # :nodoc:
43
43
 
44
44
  def next_at
45
45
  fugit = Fugit::Cron.parse(cron)
46
- fugit.next_time
46
+ fugit.next_time.to_t
47
47
  end
48
48
 
49
49
  def enqueue
50
50
  job_class.constantize.set(set_value).perform_later(*args_value)
51
+ rescue ActiveRecord::RecordNotUnique
52
+ false
51
53
  end
52
54
 
53
55
  private
@@ -82,14 +82,16 @@ module GoodJob # :nodoc:
82
82
  # Enqueues a scheduled task
83
83
  # @param cron_entry [CronEntry] the CronEntry object to schedule
84
84
  def create_task(cron_entry)
85
- delay = [(cron_entry.next_at - Time.current).to_f, 0].max
86
- future = Concurrent::ScheduledTask.new(delay, args: [self, cron_entry]) do |thr_scheduler, thr_cron_entry|
85
+ cron_at = cron_entry.next_at
86
+ delay = [(cron_at - Time.current).to_f, 0].max
87
+ future = Concurrent::ScheduledTask.new(delay, args: [self, cron_entry, cron_at]) do |thr_scheduler, thr_cron_entry, thr_cron_at|
87
88
  # Re-schedule the next cron task before executing the current task
88
89
  thr_scheduler.create_task(thr_cron_entry)
89
90
 
90
91
  Rails.application.executor.wrap do
91
92
  CurrentThread.reset
92
93
  CurrentThread.cron_key = thr_cron_entry.key
94
+ CurrentThread.cron_at = thr_cron_at
93
95
 
94
96
  cron_entry.enqueue
95
97
  end
@@ -5,6 +5,12 @@ 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 CurrentThread
8
+ # @!attribute [rw] cron_at
9
+ # @!scope class
10
+ # Cron At
11
+ # @return [DateTime, nil]
12
+ thread_mattr_accessor :cron_at
13
+
8
14
  # @!attribute [rw] cron_key
9
15
  # @!scope class
10
16
  # Cron Key
@@ -32,6 +38,7 @@ module GoodJob
32
38
  # Resets attributes
33
39
  # @return [void]
34
40
  def self.reset
41
+ self.cron_at = nil
35
42
  self.cron_key = nil
36
43
  self.execution = nil
37
44
  self.error_on_discard = nil
@@ -52,5 +59,13 @@ module GoodJob
52
59
  def self.thread_name
53
60
  (Thread.current.name || Thread.current.object_id).to_s
54
61
  end
62
+
63
+ # @return [void]
64
+ def self.within
65
+ reset
66
+ yield(self)
67
+ ensure
68
+ reset
69
+ end
55
70
  end
56
71
  end
@@ -10,6 +10,9 @@ module GoodJob
10
10
  # Raised if something attempts to execute a previously completed Execution again.
11
11
  PreviouslyPerformedError = Class.new(StandardError)
12
12
 
13
+ # String separating Error Class from Error Message
14
+ ERROR_MESSAGE_SEPARATOR = ": "
15
+
13
16
  # ActiveJob jobs without a +queue_name+ attribute are placed on this queue.
14
17
  DEFAULT_QUEUE_NAME = 'default'
15
18
  # ActiveJob jobs without a +priority+ attribute are given this priority.
@@ -50,6 +53,16 @@ module GoodJob
50
53
  end
51
54
  end
52
55
 
56
+ def self._migration_pending_warning
57
+ ActiveSupport::Deprecation.warn(<<~DEPRECATION)
58
+ GoodJob has pending database migrations. To create the migration files, run:
59
+ rails generate good_job:update
60
+ To apply the migration files, run:
61
+ rails db:migrate
62
+ DEPRECATION
63
+ nil
64
+ end
65
+
53
66
  # Get Jobs with given ActiveJob ID
54
67
  # @!method active_job_id
55
68
  # @!scope class
@@ -222,7 +235,15 @@ module GoodJob
222
235
 
223
236
  if CurrentThread.cron_key
224
237
  execution_args[:cron_key] = CurrentThread.cron_key
225
- elsif CurrentThread.active_job_id == active_job.job_id
238
+
239
+ @cron_at_index = column_names.include?('cron_at') && connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at) unless instance_variable_defined?(:@cron_at_index)
240
+
241
+ if @cron_at_index
242
+ execution_args[:cron_at] = CurrentThread.cron_at
243
+ else
244
+ _migration_pending_warning
245
+ end
246
+ elsif CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
226
247
  execution_args[:cron_key] = CurrentThread.execution.cron_key
227
248
  end
228
249
 
@@ -233,7 +254,7 @@ module GoodJob
233
254
  execution.save!
234
255
  active_job.provider_job_id = execution.id
235
256
 
236
- CurrentThread.execution.retried_good_job_id = execution.id if CurrentThread.execution && CurrentThread.execution.active_job_id == active_job.job_id
257
+ CurrentThread.execution.retried_good_job_id = execution.id if CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
237
258
 
238
259
  execution
239
260
  end
@@ -253,7 +274,7 @@ module GoodJob
253
274
  result = execute
254
275
 
255
276
  job_error = result.handled_error || result.unhandled_error
256
- self.error = "#{job_error.class}: #{job_error.message}" if job_error
277
+ self.error = [job_error.class, ERROR_MESSAGE_SEPARATOR, job_error.message].join if job_error
257
278
 
258
279
  if result.unhandled_error && GoodJob.retry_on_unhandled_error
259
280
  save!
@@ -273,19 +294,27 @@ module GoodJob
273
294
  self.class.unscoped.unfinished.owns_advisory_locked.exists?(id: id)
274
295
  end
275
296
 
297
+ def active_job
298
+ ActiveJob::Base.deserialize(active_job_data)
299
+ end
300
+
276
301
  private
277
302
 
303
+ def active_job_data
304
+ serialized_params.deep_dup
305
+ .tap do |job_data|
306
+ job_data["provider_job_id"] = id
307
+ end
308
+ end
309
+
278
310
  # @return [ExecutionResult]
279
311
  def execute
280
312
  GoodJob::CurrentThread.reset
281
313
  GoodJob::CurrentThread.execution = self
282
314
 
283
- job_data = serialized_params.deep_dup
284
- job_data["provider_job_id"] = id
285
-
286
315
  # DEPRECATION: Remove deprecated `good_job:` parameter in GoodJob v3
287
316
  ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, execution: self, process_id: GoodJob::CurrentThread.process_id, thread_name: GoodJob::CurrentThread.thread_name }) do
288
- value = ActiveJob::Base.execute(job_data)
317
+ value = ActiveJob::Base.execute(active_job_data)
289
318
 
290
319
  if value.is_a?(Exception)
291
320
  handled_error = value
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  module GoodJob
3
3
  # GoodJob gem version.
4
- VERSION = '2.4.2'
4
+ VERSION = '2.5.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: 2.4.2
4
+ version: 2.5.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-10-19 00:00:00.000000000 Z
11
+ date: 2021-10-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -369,8 +369,11 @@ files:
369
369
  - engine/app/views/good_job/shared/_executions_table.erb
370
370
  - engine/app/views/good_job/shared/_filter.erb
371
371
  - engine/app/views/good_job/shared/_jobs_table.erb
372
+ - engine/app/views/good_job/shared/icons/_arrow_clockwise.html.erb
372
373
  - engine/app/views/good_job/shared/icons/_check.html.erb
373
374
  - engine/app/views/good_job/shared/icons/_exclamation.html.erb
375
+ - engine/app/views/good_job/shared/icons/_skip_forward.html.erb
376
+ - engine/app/views/good_job/shared/icons/_stop.html.erb
374
377
  - engine/app/views/good_job/shared/icons/_trash.html.erb
375
378
  - engine/app/views/layouts/good_job/base.html.erb
376
379
  - engine/config/routes.rb
@@ -380,6 +383,8 @@ files:
380
383
  - lib/generators/good_job/install_generator.rb
381
384
  - lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb
382
385
  - lib/generators/good_job/templates/update/migrations/01_create_good_jobs.rb.erb
386
+ - lib/generators/good_job/templates/update/migrations/02_add_cron_at_to_good_jobs.rb.erb
387
+ - lib/generators/good_job/templates/update/migrations/03_add_cron_key_cron_at_index_to_good_jobs.rb.erb
383
388
  - lib/generators/good_job/update_generator.rb
384
389
  - lib/good_job.rb
385
390
  - lib/good_job/active_job_extensions.rb