maintenance_on_steroids 0.2.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 +7 -0
- data/CHANGELOG.md +267 -0
- data/LICENSE.txt +21 -0
- data/README.md +948 -0
- data/app/controllers/maintenance_on_steroids/application_controller.rb +66 -0
- data/app/controllers/maintenance_on_steroids/dashboard_controller.rb +18 -0
- data/app/controllers/maintenance_on_steroids/jobs_controller.rb +59 -0
- data/app/controllers/maintenance_on_steroids/runs_controller.rb +223 -0
- data/app/jobs/maintenance_on_steroids/run_job.rb +292 -0
- data/app/models/maintenance_on_steroids/application_record.rb +6 -0
- data/app/models/maintenance_on_steroids/artifact.rb +255 -0
- data/app/models/maintenance_on_steroids/run.rb +310 -0
- data/app/views/layouts/maintenance_on_steroids/application.html.erb +48 -0
- data/app/views/maintenance_on_steroids/dashboard/index.html.erb +163 -0
- data/app/views/maintenance_on_steroids/jobs/index.html.erb +35 -0
- data/app/views/maintenance_on_steroids/jobs/show.html.erb +107 -0
- data/app/views/maintenance_on_steroids/jobs/source.html.erb +79 -0
- data/app/views/maintenance_on_steroids/runs/new.html.erb +98 -0
- data/app/views/maintenance_on_steroids/runs/show.html.erb +410 -0
- data/app/views/maintenance_on_steroids/shared/_auto_refresh.html.erb +85 -0
- data/app/views/maintenance_on_steroids/shared/_javascript.html.erb +16 -0
- data/app/views/maintenance_on_steroids/shared/_pager.html.erb +20 -0
- data/app/views/maintenance_on_steroids/shared/_styles.html.erb +649 -0
- data/app/views/maintenance_on_steroids/shared/_task_list_item.html.erb +31 -0
- data/config/routes.rb +22 -0
- data/lib/generators/maintenance_on_steroids/install/install_generator.rb +56 -0
- data/lib/generators/maintenance_on_steroids/install/templates/create_maintenance_on_steroids_tables.rb.erb +56 -0
- data/lib/generators/maintenance_on_steroids/install/templates/initializer.rb +42 -0
- data/lib/generators/maintenance_on_steroids/job/job_generator.rb +17 -0
- data/lib/generators/maintenance_on_steroids/job/templates/job.rb.erb +30 -0
- data/lib/maintenance_on_steroids/about_dsl.rb +51 -0
- data/lib/maintenance_on_steroids/artifact_dsl.rb +82 -0
- data/lib/maintenance_on_steroids/artifacts_proxy.rb +205 -0
- data/lib/maintenance_on_steroids/callbacks_dsl.rb +61 -0
- data/lib/maintenance_on_steroids/csv_artifact.rb +88 -0
- data/lib/maintenance_on_steroids/engine.rb +26 -0
- data/lib/maintenance_on_steroids/form_dsl.rb +63 -0
- data/lib/maintenance_on_steroids/instrumentation.rb +33 -0
- data/lib/maintenance_on_steroids/job_dsl.rb +61 -0
- data/lib/maintenance_on_steroids/job_registry.rb +83 -0
- data/lib/maintenance_on_steroids/jsonb_artifact.rb +39 -0
- data/lib/maintenance_on_steroids/params_proxy.rb +93 -0
- data/lib/maintenance_on_steroids/task.rb +109 -0
- data/lib/maintenance_on_steroids/text_artifact.rb +74 -0
- data/lib/maintenance_on_steroids/version.rb +3 -0
- data/lib/maintenance_on_steroids.rb +187 -0
- metadata +125 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class ApplicationController < MaintenanceOnSteroids.parent_controller.constantize
|
|
3
|
+
protect_from_forgery with: :exception
|
|
4
|
+
|
|
5
|
+
layout "maintenance_on_steroids/application"
|
|
6
|
+
|
|
7
|
+
before_action :verify_http_basic_authentication
|
|
8
|
+
before_action :run_authentication_hook
|
|
9
|
+
before_action :verify_access
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# Step 1: HTTP Basic auth (if enabled)
|
|
14
|
+
def verify_http_basic_authentication
|
|
15
|
+
return unless MaintenanceOnSteroids.http_basic_authentication_enabled
|
|
16
|
+
|
|
17
|
+
# A blank configured password would make secure_compare(supplied, "")
|
|
18
|
+
# succeed for anyone sending an empty password. Refuse outright rather
|
|
19
|
+
# than authenticate against nothing.
|
|
20
|
+
if MaintenanceOnSteroids.http_basic_authentication_password.to_s.empty?
|
|
21
|
+
Rails.logger.error(
|
|
22
|
+
"[MaintenanceOnSteroids] HTTP Basic is enabled but the password is blank; denying access. " \
|
|
23
|
+
"Set MaintenanceOnSteroids.http_basic_authentication_password."
|
|
24
|
+
)
|
|
25
|
+
return render plain: "Access denied", status: :forbidden
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
authenticate_or_request_with_http_basic("Maintenance on Steroids") do |username, password|
|
|
29
|
+
ActiveSupport::SecurityUtils.secure_compare(username, MaintenanceOnSteroids.http_basic_authentication_user_name.to_s) &
|
|
30
|
+
ActiveSupport::SecurityUtils.secure_compare(password, MaintenanceOnSteroids.http_basic_authentication_password.to_s)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Step 2: General-purpose authentication hook (if configured)
|
|
35
|
+
def run_authentication_hook
|
|
36
|
+
return unless MaintenanceOnSteroids.authentication
|
|
37
|
+
|
|
38
|
+
instance_exec(&MaintenanceOnSteroids.authentication)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Step 3: Proc-based access check (if configured)
|
|
42
|
+
def verify_access
|
|
43
|
+
return unless MaintenanceOnSteroids.verify_access_proc
|
|
44
|
+
|
|
45
|
+
unless MaintenanceOnSteroids.verify_access_proc.call(self)
|
|
46
|
+
render plain: "Access denied", status: :forbidden
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolve the current user in controller context where Devise/Warden methods are available.
|
|
51
|
+
# The resolver proc receives the controller so it can call current_user, etc.
|
|
52
|
+
def resolve_current_user
|
|
53
|
+
resolver = MaintenanceOnSteroids.current_user_resolver
|
|
54
|
+
return nil unless resolver
|
|
55
|
+
|
|
56
|
+
if resolver.arity == 0
|
|
57
|
+
resolver.call
|
|
58
|
+
else
|
|
59
|
+
resolver.call(self)
|
|
60
|
+
end
|
|
61
|
+
rescue => e
|
|
62
|
+
Rails.logger.debug "[MaintenanceOnSteroids] Could not resolve current user: #{e.message}"
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class DashboardController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@task_classes = MaintenanceOnSteroids.task_classes
|
|
5
|
+
@active_runs = Run.active.recent.limit(25)
|
|
6
|
+
@recent_runs = Run.recent.limit(25)
|
|
7
|
+
run_ids = @active_runs.map(&:id) | @recent_runs.map(&:id)
|
|
8
|
+
@artifact_counts = Artifact.where(run_id: run_ids, kind: "output").group(:run_id).count
|
|
9
|
+
status_counts = Run.group(:status).count
|
|
10
|
+
@stats = {
|
|
11
|
+
total_tasks: @task_classes.size,
|
|
12
|
+
active_runs: status_counts.values_at(*Run::ACTIVE_STATUSES).compact.sum,
|
|
13
|
+
completed: status_counts.fetch("completed", 0),
|
|
14
|
+
errored: status_counts.fetch("errored", 0)
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class JobsController < ApplicationController
|
|
3
|
+
before_action :set_task_class, only: %i[show source]
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@task_classes = MaintenanceOnSteroids.task_classes
|
|
7
|
+
@active_run_counts = MaintenanceOnSteroids::Run.active.group(:task_class).count
|
|
8
|
+
# Fetch only the latest run per task_class (MAX(id) per group), avoiding
|
|
9
|
+
# loading every historical run. Works on both SQLite and Postgres.
|
|
10
|
+
names = @task_classes.map(&:name)
|
|
11
|
+
@last_runs = MaintenanceOnSteroids::Run
|
|
12
|
+
.where(id: MaintenanceOnSteroids::Run.where(task_class: names).group(:task_class).select("MAX(id)"))
|
|
13
|
+
.index_by(&:task_class)
|
|
14
|
+
@sort = params[:sort] == "last_run" ? "last_run" : "name"
|
|
15
|
+
# Class name is the tiebreaker in both orders. sort_by is not stable, so
|
|
16
|
+
# without it two tasks sharing a title -- or any two never-executed tasks,
|
|
17
|
+
# which all share the same epoch timestamp -- swap places between page
|
|
18
|
+
# loads for no reason.
|
|
19
|
+
@task_classes =
|
|
20
|
+
if @sort == "last_run"
|
|
21
|
+
# Most recently executed first; never-executed tasks at the bottom.
|
|
22
|
+
@task_classes.sort_by { |tc| [-(@last_runs[tc.name]&.created_at.to_i || 0), tc.name.to_s] }
|
|
23
|
+
else
|
|
24
|
+
@task_classes.sort_by { |tc| [tc.task_title.to_s.downcase, tc.name.to_s] }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def show
|
|
29
|
+
@per_page = 50
|
|
30
|
+
scope = MaintenanceOnSteroids::Run.where(task_class: @task_class.name)
|
|
31
|
+
@total_runs = scope.count
|
|
32
|
+
@total_pages = [(@total_runs.to_f / @per_page).ceil, 1].max
|
|
33
|
+
# Clamp both ends so ?page=0/-1 and ?page=99999 don't render dead pages
|
|
34
|
+
# or trigger huge offset scans.
|
|
35
|
+
@page = params[:page].to_i.clamp(1, @total_pages)
|
|
36
|
+
@runs = scope.recent.limit(@per_page).offset((@page - 1) * @per_page)
|
|
37
|
+
@artifact_counts = MaintenanceOnSteroids::Artifact
|
|
38
|
+
.where(run_id: @runs.map(&:id), kind: "output")
|
|
39
|
+
.group(:run_id).count
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def source
|
|
43
|
+
@source_file, @source_line = Object.const_source_location(@task_class.name)
|
|
44
|
+
@source_code =
|
|
45
|
+
if @source_file && @source_file.start_with?(Rails.root.to_s) && File.exist?(@source_file)
|
|
46
|
+
File.read(@source_file)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# JobRegistry.find only resolves registered Task subclasses; anything
|
|
53
|
+
# else (arbitrary user-supplied constants) returns nil -> 404.
|
|
54
|
+
def set_task_class
|
|
55
|
+
@task_class = MaintenanceOnSteroids::JobRegistry.find(params[:id])
|
|
56
|
+
raise ActiveRecord::RecordNotFound, "Unknown task: #{params[:id]}" unless @task_class
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class RunsController < ApplicationController
|
|
3
|
+
before_action :set_run, only: %i[show pause resume cancel artifact_download]
|
|
4
|
+
before_action :set_task_class, only: %i[new create]
|
|
5
|
+
|
|
6
|
+
def new
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
missing = missing_required_inputs
|
|
11
|
+
if missing.any?
|
|
12
|
+
flash.now[:alert] = "Missing required parameters: #{missing.map(&:label).join(', ')}."
|
|
13
|
+
return render :new, status: :unprocessable_entity
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
invalid = invalid_scalar_inputs
|
|
17
|
+
if invalid.any?
|
|
18
|
+
flash.now[:alert] = "Invalid value for: #{invalid.map(&:label).join(', ')}."
|
|
19
|
+
return render :new, status: :unprocessable_entity
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if concurrency_exceeded?
|
|
23
|
+
limit = @task_class.job_config.concurrency
|
|
24
|
+
flash.now[:alert] =
|
|
25
|
+
"This task allows #{limit} concurrent #{'run'.pluralize(limit)} and that many are already active."
|
|
26
|
+
return render :new, status: :unprocessable_entity
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
oversized = oversized_file_inputs
|
|
30
|
+
if oversized.any?
|
|
31
|
+
max_mb = MaintenanceOnSteroids.max_upload_size / (1024 * 1024)
|
|
32
|
+
flash.now[:alert] = "File too large for: #{oversized.map(&:label).join(', ')} (max #{max_mb} MB)."
|
|
33
|
+
return render :new, status: :unprocessable_entity
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
run = MaintenanceOnSteroids::Run.new(
|
|
37
|
+
task_class: @task_class.name,
|
|
38
|
+
status: "enqueued",
|
|
39
|
+
params: extract_scalar_params,
|
|
40
|
+
progress_current: 0,
|
|
41
|
+
progress_total: 0
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
run.record_user!(resolve_current_user)
|
|
45
|
+
|
|
46
|
+
saved = MaintenanceOnSteroids::Run.transaction do
|
|
47
|
+
if run.save
|
|
48
|
+
store_file_params(run)
|
|
49
|
+
true
|
|
50
|
+
else
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
return render :new, status: :unprocessable_entity unless saved
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
run.enqueue!
|
|
59
|
+
rescue MaintenanceOnSteroids::EnqueueFailed => e
|
|
60
|
+
return redirect_to run_path(run), alert: "Run could not be enqueued: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
redirect_to run_path(run), notice: "Task enqueued successfully."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def show
|
|
67
|
+
# May be nil when the task class no longer exists -- the view falls
|
|
68
|
+
# back to @run.task_title.
|
|
69
|
+
@task_class_obj = MaintenanceOnSteroids::JobRegistry.find(@run.task_class)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pause
|
|
73
|
+
if @run.pause!
|
|
74
|
+
redirect_to run_path(@run), notice: "Task pause requested."
|
|
75
|
+
else
|
|
76
|
+
redirect_to run_path(@run), alert: "Task cannot be paused (status: #{@run.status})."
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def resume
|
|
81
|
+
if @run.resume!
|
|
82
|
+
redirect_to run_path(@run), notice: "Task resumed."
|
|
83
|
+
else
|
|
84
|
+
redirect_to run_path(@run), alert: "Task cannot be resumed (status: #{@run.reload.status})."
|
|
85
|
+
end
|
|
86
|
+
rescue MaintenanceOnSteroids::EnqueueFailed => e
|
|
87
|
+
# Narrow on purpose: Run#resume! already rolled the run back to a terminal
|
|
88
|
+
# status, so show the operator why instead of a 500. Anything else still
|
|
89
|
+
# propagates to the host app's error reporting rather than being
|
|
90
|
+
# mislabelled as an enqueue failure.
|
|
91
|
+
redirect_to run_path(@run), alert: "Run could not be enqueued: #{e.message}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cancel
|
|
95
|
+
if @run.cancel!
|
|
96
|
+
redirect_to run_path(@run), notice: "Task cancelled."
|
|
97
|
+
else
|
|
98
|
+
redirect_to run_path(@run), alert: "Task cannot be cancelled (status: #{@run.status})."
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# JSON endpoint for progress polling -- loads only the rendered columns.
|
|
103
|
+
def status
|
|
104
|
+
run = MaintenanceOnSteroids::Run
|
|
105
|
+
.select(:id, :status, :progress_current, :progress_total, :started_at, :completed_at, :error_message)
|
|
106
|
+
.find(params[:id])
|
|
107
|
+
|
|
108
|
+
render json: {
|
|
109
|
+
status: run.status,
|
|
110
|
+
progress_current: run.progress_current,
|
|
111
|
+
progress_total: run.progress_total,
|
|
112
|
+
progress_percentage: run.progress_percentage,
|
|
113
|
+
formatted_duration: run.formatted_duration,
|
|
114
|
+
formatted_estimated_duration: run.formatted_estimated_duration,
|
|
115
|
+
error_message: run.error_message
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def artifact_download
|
|
120
|
+
artifact = @run.artifacts.find(params[:artifact_id])
|
|
121
|
+
# jsonb/text artifacts have no data_blob -- without this the response is
|
|
122
|
+
# a 200 carrying a zero-byte ".bin", which reads as "the task produced
|
|
123
|
+
# nothing" rather than "this artifact isn't a file".
|
|
124
|
+
raise ActiveRecord::RecordNotFound, "Artifact #{artifact.id} is not downloadable" unless artifact.downloadable?
|
|
125
|
+
|
|
126
|
+
send_data artifact.data_blob,
|
|
127
|
+
filename: artifact.download_file_name,
|
|
128
|
+
type: artifact.download_content_type,
|
|
129
|
+
disposition: "attachment"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def set_run
|
|
135
|
+
@run = MaintenanceOnSteroids::Run.find(params[:id])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# JobRegistry.find only resolves registered Task subclasses; anything
|
|
139
|
+
# else (arbitrary user-supplied constants) returns nil -> 404.
|
|
140
|
+
def set_task_class
|
|
141
|
+
@task_class = MaintenanceOnSteroids::JobRegistry.find(params[:job_id])
|
|
142
|
+
raise ActiveRecord::RecordNotFound, "Unknown task: #{params[:job_id]}" unless @task_class
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def missing_required_inputs
|
|
146
|
+
@task_class.form_inputs.select(&:required).select do |input|
|
|
147
|
+
value = params.dig(:task_params, input.name)
|
|
148
|
+
input.blob? ? !value.respond_to?(:read) : value.blank?
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Advisory guard for `job { concurrency N }`. Racy by nature -- two
|
|
153
|
+
# simultaneous submissions can both pass -- but it catches the case this
|
|
154
|
+
# exists for: a double-clicked New Run starting a destructive task twice.
|
|
155
|
+
def concurrency_exceeded?
|
|
156
|
+
limit = @task_class.job_config.concurrency
|
|
157
|
+
return false if limit.nil? || limit <= 0
|
|
158
|
+
|
|
159
|
+
MaintenanceOnSteroids::Run.active.where(task_class: @task_class.name).count >= limit
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The form only constrains the browser -- the posted value is whatever the
|
|
163
|
+
# client sends. Two things are rejected rather than handed to the task:
|
|
164
|
+
# a <select> value outside its declared options, and a nested structure
|
|
165
|
+
# (`task_params[name][x]=1`) where a scalar was declared.
|
|
166
|
+
def invalid_scalar_inputs
|
|
167
|
+
@task_class.form_inputs.reject(&:blob?).select do |input|
|
|
168
|
+
value = params.dig(:task_params, input.name)
|
|
169
|
+
next false if value.nil?
|
|
170
|
+
next true unless scalar_param?(value)
|
|
171
|
+
|
|
172
|
+
input.type == :select && input.options.present? &&
|
|
173
|
+
value.present? && input.options.map(&:to_s).exclude?(value.to_s)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Rails gives scalars as Strings; anything hash- or array-shaped came from
|
|
178
|
+
# a client building its own payload.
|
|
179
|
+
def scalar_param?(value)
|
|
180
|
+
!value.is_a?(Array) &&
|
|
181
|
+
!value.is_a?(Hash) &&
|
|
182
|
+
!value.is_a?(ActionController::Parameters)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def oversized_file_inputs
|
|
186
|
+
@task_class.form_inputs.select(&:blob?).select do |input|
|
|
187
|
+
file = params.dig(:task_params, input.name)
|
|
188
|
+
file.respond_to?(:size) && file.size.to_i > MaintenanceOnSteroids.max_upload_size
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def extract_scalar_params
|
|
193
|
+
scalar_inputs = @task_class.form_inputs.reject(&:blob?)
|
|
194
|
+
|
|
195
|
+
result = {}
|
|
196
|
+
scalar_inputs.each do |input|
|
|
197
|
+
value = params.dig(:task_params, input.name)
|
|
198
|
+
result[input.name] = value if value.present?
|
|
199
|
+
end
|
|
200
|
+
result
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def store_file_params(run)
|
|
204
|
+
blob_inputs = @task_class.form_inputs.select(&:blob?)
|
|
205
|
+
|
|
206
|
+
blob_inputs.each do |input|
|
|
207
|
+
file = params.dig(:task_params, input.name)
|
|
208
|
+
next unless file.respond_to?(:read)
|
|
209
|
+
|
|
210
|
+
artifact = run.artifacts.new(
|
|
211
|
+
name: input.name.to_s,
|
|
212
|
+
kind: "input",
|
|
213
|
+
artifact_type: "blob",
|
|
214
|
+
data_blob: file.read,
|
|
215
|
+
file_name: file.original_filename,
|
|
216
|
+
content_type: file.content_type
|
|
217
|
+
)
|
|
218
|
+
artifact.refresh_metadata!
|
|
219
|
+
artifact.save!
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class RunJob < ActiveJob::Base
|
|
3
|
+
include ActiveJob::Continuable
|
|
4
|
+
|
|
5
|
+
# The run row is the record of truth. If it was deleted there is nothing
|
|
6
|
+
# left to do, and retrying only produces a RecordNotFound storm in the
|
|
7
|
+
# queue -- ~21 failing attempts over a day for work that can never succeed.
|
|
8
|
+
discard_on ActiveRecord::RecordNotFound
|
|
9
|
+
|
|
10
|
+
# Continuable would otherwise silently re-enqueue a job that raised after
|
|
11
|
+
# the continuation advanced. This gem surfaces errors to an operator
|
|
12
|
+
# instead: #perform marks the run "errored" and stops, and the operator
|
|
13
|
+
# resumes it from the dashboard (Run#resumable? includes "errored").
|
|
14
|
+
# Left on, that hidden retry fires against an already-terminal run and
|
|
15
|
+
# no-ops, so the run looks retried but never advances.
|
|
16
|
+
self.resume_errors_after_advancing = false
|
|
17
|
+
attr_writer :enqueue_failure_backtrace
|
|
18
|
+
|
|
19
|
+
# Continuable dispatches retries itself, bypassing Run#enqueue!. Preserve a
|
|
20
|
+
# visible, resumable failure if that dispatch is refused or the queue is down.
|
|
21
|
+
def enqueue(...)
|
|
22
|
+
result = super
|
|
23
|
+
record_dispatch_failure!(enqueue_error || EnqueueFailed.new("Enqueue callback aborted the job")) unless result
|
|
24
|
+
result
|
|
25
|
+
rescue => e
|
|
26
|
+
record_dispatch_failure!(e)
|
|
27
|
+
raise
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def perform(run_id)
|
|
31
|
+
@run = Run.find(run_id)
|
|
32
|
+
return unless claim_run!
|
|
33
|
+
|
|
34
|
+
@task = @run.task_instance
|
|
35
|
+
@database_role = @task.class.job_config.database_role
|
|
36
|
+
@task.checkpoint_handler = method(:task_checkpoint!)
|
|
37
|
+
|
|
38
|
+
catch(:abort_run) do
|
|
39
|
+
if @first_start
|
|
40
|
+
safe_instrument(:started, @run)
|
|
41
|
+
safe_callback { @task.run_start_callbacks }
|
|
42
|
+
end
|
|
43
|
+
check_status!
|
|
44
|
+
if @task.collection_task?
|
|
45
|
+
process_collection
|
|
46
|
+
elsif @task.callable_task?
|
|
47
|
+
step :execute do |_step|
|
|
48
|
+
check_status!
|
|
49
|
+
@task.call
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
raise "Task #{@run.task_class} must define either collection+process or call"
|
|
53
|
+
end
|
|
54
|
+
complete_run!
|
|
55
|
+
end
|
|
56
|
+
rescue ExecutionLost
|
|
57
|
+
# A reaper or newer attempt owns the row. Never flush this worker's cache.
|
|
58
|
+
nil
|
|
59
|
+
rescue ActiveJob::Continuation::Interrupt
|
|
60
|
+
begin
|
|
61
|
+
@run.with_execution_lock do
|
|
62
|
+
flush_artifacts!
|
|
63
|
+
@run.update!(status: @run.running? ? "enqueued" : @run.status, execution_token: nil)
|
|
64
|
+
end
|
|
65
|
+
rescue ExecutionLost
|
|
66
|
+
return
|
|
67
|
+
rescue => e
|
|
68
|
+
record_error!(e)
|
|
69
|
+
raise e
|
|
70
|
+
end
|
|
71
|
+
raise
|
|
72
|
+
rescue => e
|
|
73
|
+
record_error!(e)
|
|
74
|
+
raise
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def record_dispatch_failure!(error)
|
|
80
|
+
return unless arguments.first
|
|
81
|
+
|
|
82
|
+
Run.where(id: arguments.first, active_job_id: job_id, execution_token: nil,
|
|
83
|
+
status: %w[enqueued pausing cancelling]).update_all(
|
|
84
|
+
status: "errored", error_message: "Failed to enqueue: #{error.message}",
|
|
85
|
+
error_backtrace: @enqueue_failure_backtrace,
|
|
86
|
+
completed_at: Time.current, updated_at: Time.current
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def claim_run!
|
|
91
|
+
@run.with_lock do
|
|
92
|
+
return false unless @run.execution_token.nil?
|
|
93
|
+
return false unless %w[enqueued pausing cancelling].include?(@run.status)
|
|
94
|
+
return false if @run.active_job_id && @run.active_job_id != job_id
|
|
95
|
+
|
|
96
|
+
@run.worker_token = SecureRandom.uuid
|
|
97
|
+
@first_start = @run.enqueued? && @run.started_at.nil?
|
|
98
|
+
attributes = { execution_token: @run.worker_token, active_job_id: job_id }
|
|
99
|
+
if @run.enqueued?
|
|
100
|
+
attributes.merge!(status: "running", started_at: @run.started_at || Time.current)
|
|
101
|
+
end
|
|
102
|
+
@run.update!(attributes)
|
|
103
|
+
end
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def record_error!(error)
|
|
108
|
+
return unless @run&.worker_token
|
|
109
|
+
|
|
110
|
+
@run.with_execution_lock do
|
|
111
|
+
safe_callback { @task&.run_error_callbacks }
|
|
112
|
+
# Cleanup must not replace the original task error. A flush error on
|
|
113
|
+
# the normal completion path reaches here as the primary exception.
|
|
114
|
+
begin
|
|
115
|
+
flush_artifacts!
|
|
116
|
+
rescue => flush_error
|
|
117
|
+
Rails.logger.error "[MaintenanceOnSteroids] Artifact cleanup error: #{flush_error.message}"
|
|
118
|
+
end
|
|
119
|
+
@run.update!(status: "errored", execution_token: nil,
|
|
120
|
+
error_message: error.message,
|
|
121
|
+
error_backtrace: error.backtrace&.first(50)&.join("\n"),
|
|
122
|
+
completed_at: Time.current)
|
|
123
|
+
end
|
|
124
|
+
safe_instrument(:errored, @run, error: error)
|
|
125
|
+
rescue ExecutionLost
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def process_collection
|
|
130
|
+
step :process_collection do |step|
|
|
131
|
+
@collection ||= with_collection_role { @task.collection }
|
|
132
|
+
warn_about_non_integer_primary_key(@collection)
|
|
133
|
+
|
|
134
|
+
if @run.progress_total.zero?
|
|
135
|
+
total = with_collection_role { @collection.count }
|
|
136
|
+
@run.with_execution_lock { @run.update!(progress_total: total) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The DB cursor (@run.cursor) is authoritative: it is written after
|
|
140
|
+
# every processed record and *before* step.set!, so it is always at
|
|
141
|
+
# least as advanced as the step cursor serialized with the job.
|
|
142
|
+
# step.cursor only matters when the DB cursor is absent.
|
|
143
|
+
effective_cursor = @run.cursor.presence || step.cursor
|
|
144
|
+
|
|
145
|
+
scope = if effective_cursor
|
|
146
|
+
@collection.unscope(:order).where(@collection.model.arel_table[@collection.model.primary_key].gt(effective_cursor))
|
|
147
|
+
else
|
|
148
|
+
@collection.unscope(:order)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The role wraps the whole scan, so every batch query hits the replica.
|
|
152
|
+
# Each record's work steps back to :writing -- process may write, and
|
|
153
|
+
# the run's own cursor/progress bookkeeping always does.
|
|
154
|
+
with_collection_role do
|
|
155
|
+
scope.order(@collection.model.primary_key => :asc).find_each do |record|
|
|
156
|
+
with_writing_role do
|
|
157
|
+
check_status!
|
|
158
|
+
|
|
159
|
+
checkpointed = false
|
|
160
|
+
begin
|
|
161
|
+
@processing_record = true
|
|
162
|
+
@task.process(record)
|
|
163
|
+
cursor_value = record.public_send(record.class.primary_key)
|
|
164
|
+
# A crash commits both output and cursor, or neither. Host task
|
|
165
|
+
# side effects still need idempotency (they may use other DBs/APIs).
|
|
166
|
+
@run.with_execution_lock do
|
|
167
|
+
flush_artifacts!
|
|
168
|
+
advance_progress!(cursor_value)
|
|
169
|
+
end
|
|
170
|
+
checkpointed = true
|
|
171
|
+
ensure
|
|
172
|
+
@processing_record = false
|
|
173
|
+
# A failed record must not leak buffered rows into error cleanup.
|
|
174
|
+
@task.artifacts.discard! unless checkpointed
|
|
175
|
+
end
|
|
176
|
+
# Record the last processed pk as an exclusive cursor (resume uses gt(cursor)),
|
|
177
|
+
# matching @run.cursor semantics. advance! would store pk+1 and skip a record.
|
|
178
|
+
step.set!(cursor_value)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Both are pass-throughs unless the task declared `job { database_role ... }`,
|
|
186
|
+
# so the default path pays nothing for connection switching.
|
|
187
|
+
def with_collection_role(&block)
|
|
188
|
+
return yield unless @database_role
|
|
189
|
+
|
|
190
|
+
ActiveRecord::Base.connected_to(role: @database_role, &block)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def with_writing_role(&block)
|
|
194
|
+
return yield unless @database_role
|
|
195
|
+
|
|
196
|
+
ActiveRecord::Base.connected_to(role: :writing, &block)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Called while holding the execution lock and the output transaction.
|
|
200
|
+
def advance_progress!(cursor_value)
|
|
201
|
+
@run.update!(progress_current: @run.progress_current + 1, cursor: cursor_value.to_s)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def complete_run!
|
|
205
|
+
completed = false
|
|
206
|
+
@run.with_execution_lock do
|
|
207
|
+
if @run.running?
|
|
208
|
+
# Commit callback output and completion together. An operator waiting
|
|
209
|
+
# on this short finalization lock will then see the final state.
|
|
210
|
+
safe_callback { @task.run_complete_callbacks }
|
|
211
|
+
flush_artifacts!
|
|
212
|
+
@run.reload
|
|
213
|
+
if @run.running?
|
|
214
|
+
@run.update!(status: "completed", completed_at: Time.current, execution_token: nil)
|
|
215
|
+
completed = true
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
if completed
|
|
220
|
+
safe_instrument(:succeeded, @run)
|
|
221
|
+
else
|
|
222
|
+
check_status!
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def warn_about_non_integer_primary_key(collection)
|
|
227
|
+
return if @pk_checked
|
|
228
|
+
|
|
229
|
+
@pk_checked = true
|
|
230
|
+
model = collection.model
|
|
231
|
+
pk_type = model.columns_hash[model.primary_key.to_s]&.type
|
|
232
|
+
return if pk_type == :integer
|
|
233
|
+
|
|
234
|
+
Rails.logger.warn(
|
|
235
|
+
"[MaintenanceOnSteroids] #{@run.task_class}: collection primary key " \
|
|
236
|
+
"#{model.primary_key.inspect} has type #{pk_type.inspect}. Cursor-based " \
|
|
237
|
+
"resumption relies on monotonically increasing primary keys; " \
|
|
238
|
+
"UUID/string keys can skip or repeat records on resume."
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def task_checkpoint!
|
|
243
|
+
if @processing_record
|
|
244
|
+
# Do not persist partial record output without its cursor. Collection
|
|
245
|
+
# pause/cancel takes effect at the next record boundary.
|
|
246
|
+
@run.with_execution_lock { @run.touch }
|
|
247
|
+
else
|
|
248
|
+
check_status!
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def check_status!
|
|
253
|
+
event = nil
|
|
254
|
+
@run.with_execution_lock do
|
|
255
|
+
case @run.status
|
|
256
|
+
when "pausing", "cancelling"
|
|
257
|
+
safe_callback { @task.run_interrupt_callbacks }
|
|
258
|
+
# Re-read in case a callback requested cancellation instead of pause.
|
|
259
|
+
@run.reload
|
|
260
|
+
event = @run.cancelling? ? :cancelled : :paused
|
|
261
|
+
safe_callback { event == :paused ? @task.run_pause_callbacks : @task.run_cancel_callbacks }
|
|
262
|
+
flush_artifacts!
|
|
263
|
+
@run.update!(status: event.to_s, execution_token: nil,
|
|
264
|
+
completed_at: event == :cancelled ? Time.current : nil)
|
|
265
|
+
else
|
|
266
|
+
# Callable checkpoints are a real heartbeat and persist their output.
|
|
267
|
+
# Collection output is committed only alongside its record cursor.
|
|
268
|
+
flush_artifacts! unless @task.collection_task?
|
|
269
|
+
@run.touch
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
if event
|
|
273
|
+
safe_instrument(event, @run)
|
|
274
|
+
throw :abort_run
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def safe_callback
|
|
279
|
+
yield
|
|
280
|
+
rescue => e
|
|
281
|
+
Rails.logger.error "[MaintenanceOnSteroids] Callback error: #{e.message}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def safe_instrument(event, run, extra = {})
|
|
285
|
+
MaintenanceOnSteroids::Instrumentation.safe_instrument(event, run, extra)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def flush_artifacts!
|
|
289
|
+
@task&.artifacts&.flush!
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|