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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +267 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +948 -0
  5. data/app/controllers/maintenance_on_steroids/application_controller.rb +66 -0
  6. data/app/controllers/maintenance_on_steroids/dashboard_controller.rb +18 -0
  7. data/app/controllers/maintenance_on_steroids/jobs_controller.rb +59 -0
  8. data/app/controllers/maintenance_on_steroids/runs_controller.rb +223 -0
  9. data/app/jobs/maintenance_on_steroids/run_job.rb +292 -0
  10. data/app/models/maintenance_on_steroids/application_record.rb +6 -0
  11. data/app/models/maintenance_on_steroids/artifact.rb +255 -0
  12. data/app/models/maintenance_on_steroids/run.rb +310 -0
  13. data/app/views/layouts/maintenance_on_steroids/application.html.erb +48 -0
  14. data/app/views/maintenance_on_steroids/dashboard/index.html.erb +163 -0
  15. data/app/views/maintenance_on_steroids/jobs/index.html.erb +35 -0
  16. data/app/views/maintenance_on_steroids/jobs/show.html.erb +107 -0
  17. data/app/views/maintenance_on_steroids/jobs/source.html.erb +79 -0
  18. data/app/views/maintenance_on_steroids/runs/new.html.erb +98 -0
  19. data/app/views/maintenance_on_steroids/runs/show.html.erb +410 -0
  20. data/app/views/maintenance_on_steroids/shared/_auto_refresh.html.erb +85 -0
  21. data/app/views/maintenance_on_steroids/shared/_javascript.html.erb +16 -0
  22. data/app/views/maintenance_on_steroids/shared/_pager.html.erb +20 -0
  23. data/app/views/maintenance_on_steroids/shared/_styles.html.erb +649 -0
  24. data/app/views/maintenance_on_steroids/shared/_task_list_item.html.erb +31 -0
  25. data/config/routes.rb +22 -0
  26. data/lib/generators/maintenance_on_steroids/install/install_generator.rb +56 -0
  27. data/lib/generators/maintenance_on_steroids/install/templates/create_maintenance_on_steroids_tables.rb.erb +56 -0
  28. data/lib/generators/maintenance_on_steroids/install/templates/initializer.rb +42 -0
  29. data/lib/generators/maintenance_on_steroids/job/job_generator.rb +17 -0
  30. data/lib/generators/maintenance_on_steroids/job/templates/job.rb.erb +30 -0
  31. data/lib/maintenance_on_steroids/about_dsl.rb +51 -0
  32. data/lib/maintenance_on_steroids/artifact_dsl.rb +82 -0
  33. data/lib/maintenance_on_steroids/artifacts_proxy.rb +205 -0
  34. data/lib/maintenance_on_steroids/callbacks_dsl.rb +61 -0
  35. data/lib/maintenance_on_steroids/csv_artifact.rb +88 -0
  36. data/lib/maintenance_on_steroids/engine.rb +26 -0
  37. data/lib/maintenance_on_steroids/form_dsl.rb +63 -0
  38. data/lib/maintenance_on_steroids/instrumentation.rb +33 -0
  39. data/lib/maintenance_on_steroids/job_dsl.rb +61 -0
  40. data/lib/maintenance_on_steroids/job_registry.rb +83 -0
  41. data/lib/maintenance_on_steroids/jsonb_artifact.rb +39 -0
  42. data/lib/maintenance_on_steroids/params_proxy.rb +93 -0
  43. data/lib/maintenance_on_steroids/task.rb +109 -0
  44. data/lib/maintenance_on_steroids/text_artifact.rb +74 -0
  45. data/lib/maintenance_on_steroids/version.rb +3 -0
  46. data/lib/maintenance_on_steroids.rb +187 -0
  47. 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
@@ -0,0 +1,6 @@
1
+ module MaintenanceOnSteroids
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ self.table_name_prefix = "maintenance_on_steroids_"
5
+ end
6
+ end