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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +11 -3
- data/engine/app/controllers/good_job/jobs_controller.rb +36 -0
- data/engine/app/filters/good_job/base_filter.rb +6 -2
- data/engine/app/filters/good_job/jobs_filter.rb +3 -1
- data/engine/app/models/good_job/active_job_job.rb +130 -12
- data/engine/app/views/good_job/cron_schedules/index.html.erb +1 -1
- data/engine/app/views/good_job/jobs/index.html.erb +14 -1
- data/engine/app/views/good_job/shared/_jobs_table.erb +17 -5
- data/engine/app/views/good_job/shared/icons/_arrow_clockwise.html.erb +5 -0
- data/engine/app/views/good_job/shared/icons/_skip_forward.html.erb +4 -0
- data/engine/app/views/good_job/shared/icons/_stop.html.erb +4 -0
- data/engine/config/routes.rb +7 -1
- data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +2 -0
- data/lib/generators/good_job/templates/update/migrations/02_add_cron_at_to_good_jobs.rb.erb +14 -0
- data/lib/generators/good_job/templates/update/migrations/03_add_cron_key_cron_at_index_to_good_jobs.rb.erb +20 -0
- data/lib/good_job/cron_entry.rb +3 -1
- data/lib/good_job/cron_manager.rb +4 -2
- data/lib/good_job/current_thread.rb +15 -0
- data/lib/good_job/execution.rb +36 -7
- data/lib/good_job/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5a173683ec5e5879728536005c7dfd11827ffe87c268c59315f540911277f2df
|
4
|
+
data.tar.gz: a5990d902838da25344ff96fc859bad39803f70f888dd95de8e3e6c201da4b0f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
366
|
+
# Maximum number of jobs with the concurrency key to be
|
367
|
+
# concurrently enqueued (excludes performing jobs)
|
367
368
|
enqueue_limit: 2,
|
368
|
-
|
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
|
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,
|
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
|
-
#
|
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
|
-
|
64
|
-
|
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
|
-
|
73
|
-
|
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
|
-
|
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
|
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
|
@@ -4,4 +4,17 @@
|
|
4
4
|
|
5
5
|
<%= render 'good_job/shared/filter', filter: @filter %>
|
6
6
|
|
7
|
-
|
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">»</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
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
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>
|
data/engine/config/routes.rb
CHANGED
@@ -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
|
data/lib/good_job/cron_entry.rb
CHANGED
@@ -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
|
-
|
86
|
-
|
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
|
data/lib/good_job/execution.rb
CHANGED
@@ -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
|
-
|
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.
|
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 =
|
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(
|
317
|
+
value = ActiveJob::Base.execute(active_job_data)
|
289
318
|
|
290
319
|
if value.is_a?(Exception)
|
291
320
|
handled_error = value
|
data/lib/good_job/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: good_job
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 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-
|
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
|