Skip to content

Commit

Permalink
Add Reschedule, Discard, Retry buttons to Dashboard
Browse files Browse the repository at this point in the history
  • Loading branch information
bensheldon committed Oct 20, 2021
1 parent 377cde2 commit b1da1a1
Show file tree
Hide file tree
Showing 14 changed files with 358 additions and 37 deletions.
36 changes: 36 additions & 0 deletions engine/app/controllers/good_job/jobs_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# frozen_string_literal: true
module GoodJob
class JobsController < GoodJob::BaseController
rescue_from GoodJob::ActiveJobJob::AdapterNotGoodJobError,
GoodJob::ActiveJobJob::ActionForStateMismatchError,
with: :redirect_on_error

def index
@filter = JobsFilter.new(params)
end
Expand All @@ -10,5 +14,37 @@ def show
.order(Arel.sql("COALESCE(scheduled_at, created_at) DESC"))
redirect_to root_path, alert: "Executions for Active Job #{params[:id]} not found" if @executions.empty?
end

def discard
@job = ActiveJobJob.find(params[:id])
@job.discard_job("Discarded through dashboard")
redirect_back(fallback_location: jobs_path, notice: "Job has been discarded")
end

def reschedule
@job = ActiveJobJob.find(params[:id])
@job.reschedule_job
redirect_back(fallback_location: jobs_path, notice: "Job has been rescheduled")
end

def retry
@job = ActiveJobJob.find(params[:id])
@job.retry_job
redirect_back(fallback_location: jobs_path, notice: "Job has been retried")
end

private

def redirect_on_error(exception)
alert = case exception
when GoodJob::ActiveJobJob::AdapterNotGoodJobError
"ActiveJob Queue Adapter must be GoodJob."
when GoodJob::ActiveJobJob::ActionForStateMismatchError
"Job is not in an appropriate state for this action."
else
exception.to_s
end
redirect_back(fallback_location: jobs_path, alert: alert)
end
end
end
4 changes: 3 additions & 1 deletion engine/app/filters/good_job/jobs_filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ def base_query
end

def filtered_query
query = base_query
query = base_query.includes(:executions)
.joins_advisory_locks.select('good_jobs.*', 'pg_locks.locktype AS locktype')

query = query.job_class(params[:job_class]) if params[:job_class]
query = query.where(queue_name: params[:queue_name]) if params[:queue_name]

Expand Down
76 changes: 69 additions & 7 deletions engine/app/models/good_job/active_job_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ module GoodJob
class ActiveJobJob < Object.const_get(GoodJob.active_record_parent_class)
include GoodJob::Lockable

ActionForStateMismatchError = Class.new(StandardError)
AdapterNotGoodJobError = Class.new(StandardError)
DiscardJobError = Class.new(StandardError)

self.table_name = 'good_jobs'
self.primary_key = 'active_job_id'
self.advisory_lockable_column = 'active_job_id'
Expand Down Expand Up @@ -69,14 +73,15 @@ def job_class
end

def status
if finished_at.present?
if error.present?
execution = head_execution
if execution.finished_at.present?
if execution.error.present?
:discarded
else
:finished
end
elsif (scheduled_at || created_at) > DateTime.current
if serialized_params.fetch('executions', 0) > 1
elsif (execution.scheduled_at || execution.created_at) > DateTime.current
if execution.serialized_params.fetch('executions', 0) > 1
:retried
else
:scheduled
Expand All @@ -88,7 +93,13 @@ def status
end
end

def head_execution
def head?
_execution_id == head_execution(reload: true).id
end

def head_execution(reload: false)
executions.reload if reload
executions.load # memoize the results
executions.last
end

Expand All @@ -97,7 +108,7 @@ def tail_execution
end

def executions_count
aj_count = serialized_params.fetch('executions', 0)
aj_count = head_execution.serialized_params.fetch('executions', 0)
# The execution count within serialized_params is not updated
# once the underlying execution has been executed.
if status.in? [:discarded, :finished, :running]
Expand All @@ -112,7 +123,7 @@ def preserved_executions_count
end

def recent_error
error.presence || executions[-2]&.error
head_execution.error || executions[-2]&.error
end

def running?
Expand All @@ -123,5 +134,56 @@ def running?
advisory_locked?
end
end

def retry_job
with_advisory_lock do
execution = head_execution(reload: true)
active_job = execution.active_job

raise AdapterNotGoodJobError unless active_job.class.queue_adapter.is_a? GoodJob::Adapter
raise ActionForStateMismatchError unless status == :discarded

# Update the executions count because the previous execution will not have been preserved
# Do not update `exception_executions` because that comes from rescue_from's arguments
active_job.executions = (active_job.executions || 0) + 1

new_active_job = nil
GoodJob::CurrentThread.within do |current_thread|
current_thread.execution = execution

execution.class.transaction(joinable: false, requires_new: true) do
new_active_job = active_job.retry_job(wait: 0, error: error)
execution.save
end
end
new_active_job
end
end

def discard_job(message)
with_advisory_lock do
raise ActionForStateMismatchError unless status.in? [:scheduled, :queued, :retried]

execution = head_execution(reload: true)
active_job = execution.active_job

job_error = GoodJob::ActiveJobJob::DiscardJobError.new(message)
active_job.send :instrument, :discard, error: job_error

execution.update(
finished_at: Time.current,
error: [job_error.class, GoodJob::Execution::ERROR_MESSAGE_SEPARATOR, job_error.message].join
)
end
end

def reschedule_job(scheduled_at = Time.current)
with_advisory_lock do
raise ActionForStateMismatchError unless status.in? [:scheduled, :queued, :retried]

execution = head_execution(reload: true)
execution.update(scheduled_at: scheduled_at)
end
end
end
end
20 changes: 15 additions & 5 deletions engine/app/views/good_job/shared/_jobs_table.erb
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,21 @@
%>
<%= tag.pre JSON.pretty_generate(job.serialized_params), id: dom_id(job, "params"), class: "collapse job-params" %>
</td>
<!-- <td>-->
<%#= button_to execution_path(execution.id), method: :delete, class: "btn btn-sm btn-outline-danger", title: "Delete execution" do %>
<%#= render "good_job/shared/icons/trash" %>
<%# end %>
<!-- </td>-->
<td>
<% job_reschedulable = job.status.in? [:scheduled, :retried, :queued] %>
<%= 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 %>
<%= render "good_job/shared/icons/skip_forward" %>
<% end %>
<% job_discardable = job.status.in? [:scheduled, :retried, :queued] %>
<%= 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 %>
<%= render "good_job/shared/icons/stop" %>
<% end %>
<%= 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 %>
<%= render "good_job/shared/icons/arrow_clockwise" %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- https://icons.getbootstrap.com/icons/arrow-clockwise/ -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16">
<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" />
<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" />
</svg>
4 changes: 4 additions & 0 deletions engine/app/views/good_job/shared/icons/_skip_forward.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!-- https://icons.getbootstrap.com/icons/skip-forward/ -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-skip-forward" viewBox="0 0 16 16">
<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" />
</svg>
4 changes: 4 additions & 0 deletions engine/app/views/good_job/shared/icons/_stop.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!-- https://icons.getbootstrap.com/icons/stop/ -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-stop" viewBox="0 0 16 16">
<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" />
</svg>
8 changes: 7 additions & 1 deletion engine/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
GoodJob::Engine.routes.draw do
root to: 'executions#index'
resources :cron_schedules, only: %i[index]
resources :jobs, only: %i[index show]
resources :jobs, only: %i[index show] do
member do
put :discard
put :reschedule
put :retry
end
end
resources :executions, only: %i[destroy]

scope controller: :assets do
Expand Down
8 changes: 8 additions & 0 deletions lib/good_job/current_thread.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,13 @@ def self.process_id
def self.thread_name
(Thread.current.name || Thread.current.object_id).to_s
end

# @return [void]
def self.within
reset
yield(self)
ensure
reset
end
end
end
25 changes: 18 additions & 7 deletions lib/good_job/execution.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ class Execution < Object.const_get(GoodJob.active_record_parent_class)
# Raised if something attempts to execute a previously completed Execution again.
PreviouslyPerformedError = Class.new(StandardError)

# String separating Error Class from Error Message
ERROR_MESSAGE_SEPARATOR = ": "

# ActiveJob jobs without a +queue_name+ attribute are placed on this queue.
DEFAULT_QUEUE_NAME = 'default'
# ActiveJob jobs without a +priority+ attribute are given this priority.
Expand Down Expand Up @@ -228,7 +231,7 @@ def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false

if CurrentThread.cron_key
execution_args[:cron_key] = CurrentThread.cron_key
elsif CurrentThread.active_job_id == active_job.job_id
elsif CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
execution_args[:cron_key] = CurrentThread.execution.cron_key
end

Expand All @@ -239,7 +242,7 @@ def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false
execution.save!
active_job.provider_job_id = execution.id

CurrentThread.execution.retried_good_job_id = execution.id if CurrentThread.execution && CurrentThread.execution.active_job_id == active_job.job_id
CurrentThread.execution.retried_good_job_id = execution.id if CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id

execution
end
Expand All @@ -259,7 +262,7 @@ def perform
result = execute

job_error = result.handled_error || result.unhandled_error
self.error = "#{job_error.class}: #{job_error.message}" if job_error
self.error = [job_error.class, ERROR_MESSAGE_SEPARATOR, job_error.message].join if job_error

if result.unhandled_error && GoodJob.retry_on_unhandled_error
save!
Expand All @@ -279,19 +282,27 @@ def executable?
self.class.unscoped.unfinished.owns_advisory_locked.exists?(id: id)
end

def active_job
ActiveJob::Base.deserialize(active_job_data)
end

private

def active_job_data
serialized_params.deep_dup
.tap do |job_data|
job_data["provider_job_id"] = id
end
end

# @return [ExecutionResult]
def execute
GoodJob::CurrentThread.reset
GoodJob::CurrentThread.execution = self

job_data = serialized_params.deep_dup
job_data["provider_job_id"] = id

# DEPRECATION: Remove deprecated `good_job:` parameter in GoodJob v3
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
value = ActiveJob::Base.execute(job_data)
value = ActiveJob::Base.execute(active_job_data)

if value.is_a?(Exception)
handled_error = value
Expand Down
Loading

0 comments on commit b1da1a1

Please sign in to comment.