good_job 2.4.2 → 2.5.0

Sign up to get free protection for your applications and to get access to all the features.
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