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,26 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace MaintenanceOnSteroids
|
|
4
|
+
|
|
5
|
+
initializer "maintenance_on_steroids.autoload_tasks", before: :set_autoload_paths do |app|
|
|
6
|
+
tasks_path = Rails.root.join("app/maintenance")
|
|
7
|
+
if tasks_path.exist?
|
|
8
|
+
app.config.autoload_paths += [tasks_path.to_s]
|
|
9
|
+
app.config.eager_load_paths += [tasks_path.to_s]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Invalidate the registry on code reload in development so it never
|
|
14
|
+
# holds stale (unloaded) task class objects.
|
|
15
|
+
config.to_prepare do
|
|
16
|
+
MaintenanceOnSteroids::JobRegistry.reset!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Access control is opt-in, so the dangerous configuration is the empty
|
|
20
|
+
# one -- and HTTP Basic left on its shipped password is the same thing
|
|
21
|
+
# wearing a badge. See MaintenanceOnSteroids.verify_access_control!.
|
|
22
|
+
config.after_initialize do
|
|
23
|
+
MaintenanceOnSteroids.verify_access_control!
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
module FormDsl
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
class InputDefinition
|
|
6
|
+
attr_reader :name, :type, :required, :default, :options, :label, :placeholder, :help_text
|
|
7
|
+
|
|
8
|
+
def initialize(name, type: :string, required: false, default: nil, options: nil, label: nil, placeholder: nil, help_text: nil)
|
|
9
|
+
@name = name.to_sym
|
|
10
|
+
@type = type.to_sym
|
|
11
|
+
@required = required
|
|
12
|
+
@default = default
|
|
13
|
+
@options = options
|
|
14
|
+
@label = label || name.to_s.humanize
|
|
15
|
+
@placeholder = placeholder
|
|
16
|
+
@help_text = help_text
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def blob?
|
|
20
|
+
type == :blob
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def html_input_type
|
|
24
|
+
case type
|
|
25
|
+
when :string then "text"
|
|
26
|
+
when :integer then "number"
|
|
27
|
+
when :float then "number"
|
|
28
|
+
when :boolean then "checkbox"
|
|
29
|
+
when :text then "textarea"
|
|
30
|
+
when :date then "date"
|
|
31
|
+
when :datetime then "datetime-local"
|
|
32
|
+
when :blob then "file"
|
|
33
|
+
when :select then "select"
|
|
34
|
+
else "text"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class FormBuilder
|
|
40
|
+
attr_reader :inputs
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
@inputs = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def input(name, **options)
|
|
47
|
+
@inputs << InputDefinition.new(name, **options)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class_methods do
|
|
52
|
+
def form(&block)
|
|
53
|
+
builder = FormBuilder.new
|
|
54
|
+
builder.instance_eval(&block)
|
|
55
|
+
@form_inputs = builder.inputs
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def form_inputs
|
|
59
|
+
@form_inputs || []
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
# Emits ActiveSupport::Notifications events for run lifecycle transitions.
|
|
3
|
+
#
|
|
4
|
+
# ActiveSupport::Notifications.subscribe("enqueued.maintenance_on_steroids") do |event|
|
|
5
|
+
# run = event.payload[:run]
|
|
6
|
+
# Rails.logger.info "Enqueued #{event.payload[:task_name]} (run ##{run.id})"
|
|
7
|
+
# end
|
|
8
|
+
#
|
|
9
|
+
# Events: enqueued, started, paused, resumed, cancelled, succeeded, errored.
|
|
10
|
+
# Payload: { run:, task_name: } plus { error: } for errored.
|
|
11
|
+
module Instrumentation
|
|
12
|
+
NAMESPACE = "maintenance_on_steroids"
|
|
13
|
+
|
|
14
|
+
def self.instrument(event, run, extra = {})
|
|
15
|
+
ActiveSupport::Notifications.instrument(
|
|
16
|
+
"#{event}.#{NAMESPACE}",
|
|
17
|
+
{ run: run, task_name: run.task_class }.merge(extra)
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Best-effort instrumentation: AS::Notifications re-raises subscriber
|
|
22
|
+
# exceptions, so every lifecycle call routes through here. A raising
|
|
23
|
+
# subscriber must never affect run outcomes (corrupt status, 500 a
|
|
24
|
+
# controller, trigger a retry storm) -- the error is logged and swallowed.
|
|
25
|
+
# See docs/solutions/runtime-errors/unguarded-instrumentation-corrupts-run-status-2026-06-15.md
|
|
26
|
+
def self.safe_instrument(event, run, extra = {})
|
|
27
|
+
instrument(event, run, extra)
|
|
28
|
+
rescue => e
|
|
29
|
+
Rails.logger.error "[MaintenanceOnSteroids] Instrumentation error (#{event}): " \
|
|
30
|
+
"#{e.class}: #{e.message}\n#{e.backtrace&.first(3)&.join("\n")}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
module JobDsl
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
class JobConfig
|
|
6
|
+
attr_reader :queue_name
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@queue_name = nil
|
|
10
|
+
@priority = nil
|
|
11
|
+
@database_role = nil
|
|
12
|
+
@concurrency = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def queue(name)
|
|
16
|
+
@queue_name = name.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Acts as both DSL setter (priority 10) and reader (config.priority).
|
|
20
|
+
def priority(value = :__unset__)
|
|
21
|
+
return @priority if value == :__unset__
|
|
22
|
+
|
|
23
|
+
@priority = value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Maximum number of runs of this task that may be active at once.
|
|
27
|
+
# `concurrency 1` is the usual choice for a destructive task: without it
|
|
28
|
+
# a double-click on New Run happily starts the same migration twice.
|
|
29
|
+
# nil (the default) means unlimited. Acts as setter and reader.
|
|
30
|
+
def concurrency(value = :__unset__)
|
|
31
|
+
return @concurrency if value == :__unset__
|
|
32
|
+
|
|
33
|
+
@concurrency = value&.to_i
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Database role the collection is read under, e.g. `database_role :reading`
|
|
37
|
+
# to scan a replica. Declared rather than block-scoped because a
|
|
38
|
+
# `collection` is a lazy Relation: wrapping the method body in
|
|
39
|
+
# connected_to switches back before the query ever runs. RunJob holds the
|
|
40
|
+
# role open for the whole scan instead, and runs `process` plus its own
|
|
41
|
+
# bookkeeping under :writing. Acts as setter and reader.
|
|
42
|
+
def database_role(value = :__unset__)
|
|
43
|
+
return @database_role if value == :__unset__
|
|
44
|
+
|
|
45
|
+
@database_role = MaintenanceOnSteroids::Task.normalize_database_role(value)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class_methods do
|
|
50
|
+
def job(&block)
|
|
51
|
+
config = JobConfig.new
|
|
52
|
+
config.instance_eval(&block)
|
|
53
|
+
@job_config = config
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def job_config
|
|
57
|
+
@job_config || JobConfig.new
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
module JobRegistry
|
|
3
|
+
LOAD_MUTEX = Mutex.new
|
|
4
|
+
|
|
5
|
+
class << self
|
|
6
|
+
def register(task_class)
|
|
7
|
+
registry[task_class.name] = task_class
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def unregister(task_class_name)
|
|
11
|
+
registry.delete(task_class_name)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def tasks
|
|
15
|
+
load_all!
|
|
16
|
+
registry.values.sort_by(&:name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Resolves a task class by name.
|
|
20
|
+
#
|
|
21
|
+
# Only returns registered tasks or already-loaded Task descendants.
|
|
22
|
+
# Request and persisted names never trigger arbitrary constant loading.
|
|
23
|
+
def find(class_name)
|
|
24
|
+
load_all!
|
|
25
|
+
class_name = class_name.to_s
|
|
26
|
+
return registry[class_name] if registry.key?(class_name)
|
|
27
|
+
|
|
28
|
+
# Never constantize request or persisted input. Descendants also cover
|
|
29
|
+
# tasks defined outside app/maintenance without loading arbitrary constants.
|
|
30
|
+
MaintenanceOnSteroids::Task.descendants.find do |klass|
|
|
31
|
+
klass.name == class_name && klass.name.safe_constantize.equal?(klass)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def load_all!
|
|
36
|
+
return if @loaded
|
|
37
|
+
|
|
38
|
+
LOAD_MUTEX.synchronize do
|
|
39
|
+
return if @loaded
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
tasks_path = Rails.root.join("app/maintenance")
|
|
43
|
+
if tasks_path.exist?
|
|
44
|
+
Rails.autoloaders.main.eager_load_dir(tasks_path.to_s)
|
|
45
|
+
end
|
|
46
|
+
rescue => e
|
|
47
|
+
Rails.logger.warn "[MaintenanceOnSteroids] Failed to eager load tasks: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# eager_load_dir is a no-op for constants Zeitwerk already loaded,
|
|
51
|
+
# so after a reset! (tests, code reload) the inherited-hook
|
|
52
|
+
# registrations are gone. Sweep descendants to re-register them.
|
|
53
|
+
#
|
|
54
|
+
# Only classes their own constant still resolves to: a class keeps the
|
|
55
|
+
# name it was first assigned even after the constant is removed or
|
|
56
|
+
# rebound, and it stays in descendants either way. Registering those
|
|
57
|
+
# blindly makes the dashboard list ghost tasks -- stale copies after a
|
|
58
|
+
# code reload in development, and every stubbed class a test ever
|
|
59
|
+
# defined.
|
|
60
|
+
MaintenanceOnSteroids::Task.descendants.each do |klass|
|
|
61
|
+
next if klass.name.blank?
|
|
62
|
+
next unless klass.name.safe_constantize.equal?(klass)
|
|
63
|
+
|
|
64
|
+
register(klass)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@loaded = true
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def reset!
|
|
72
|
+
@loaded = false
|
|
73
|
+
@registry = {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def registry
|
|
79
|
+
@registry ||= {}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class JsonbArtifact < HashWithIndifferentAccess
|
|
3
|
+
# Accepts either an Artifact record (normal construction) or a plain
|
|
4
|
+
# hash. HashWithIndifferentAccess internals (dup/merge/except/...) build
|
|
5
|
+
# copies via `self.class.new(hash)`, so the hash form must be supported.
|
|
6
|
+
# Such copies are "detached": they behave like hashes but cannot save!.
|
|
7
|
+
def initialize(record_or_hash = {})
|
|
8
|
+
if record_or_hash.respond_to?(:data_jsonb)
|
|
9
|
+
@_record = record_or_hash
|
|
10
|
+
super(record_or_hash.data_jsonb || {})
|
|
11
|
+
else
|
|
12
|
+
@_record = nil
|
|
13
|
+
super(record_or_hash || {})
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def save!
|
|
18
|
+
unless @_record
|
|
19
|
+
raise "Cannot save! a detached JsonbArtifact copy (created via dup/merge/except). Save the original artifact instead."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
@_record.data_jsonb = to_h
|
|
23
|
+
@_record.refresh_metadata!
|
|
24
|
+
@_record.save!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# True when the in-memory contents differ from what's persisted (or from
|
|
28
|
+
# the lazy default for an unsaved record). Lets the proxy auto-flush only
|
|
29
|
+
# artifacts that were actually written, avoiding phantom rows for reads.
|
|
30
|
+
def dirty?
|
|
31
|
+
return false unless @_record
|
|
32
|
+
to_h != (@_record.data_jsonb || {})
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def record
|
|
36
|
+
@_record
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class ParamsProxy
|
|
3
|
+
def initialize(run, form_inputs)
|
|
4
|
+
@run = run
|
|
5
|
+
@form_inputs = form_inputs.index_by(&:name)
|
|
6
|
+
@scalar_data = (run.params || {}).with_indifferent_access
|
|
7
|
+
@blob_cache = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def [](key)
|
|
11
|
+
key = key.to_sym
|
|
12
|
+
input = @form_inputs[key]
|
|
13
|
+
|
|
14
|
+
if input&.blob?
|
|
15
|
+
input_artifact(key)&.data_blob
|
|
16
|
+
else
|
|
17
|
+
value = @scalar_data[key]
|
|
18
|
+
value = input.default if value.nil? && input
|
|
19
|
+
cast_value(value, input&.type)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Original filename of an uploaded file input (nil if none uploaded, or if
|
|
24
|
+
# the key isn't a declared blob input -- scalar inputs have no input
|
|
25
|
+
# artifact, so don't waste a query looking one up).
|
|
26
|
+
def file_name(key)
|
|
27
|
+
return nil unless @form_inputs[key.to_sym]&.blob?
|
|
28
|
+
|
|
29
|
+
input_artifact(key)&.file_name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Declared MIME type of an uploaded file input (nil if none uploaded, or if
|
|
33
|
+
# the key isn't a declared blob input).
|
|
34
|
+
def content_type(key)
|
|
35
|
+
return nil unless @form_inputs[key.to_sym]&.blob?
|
|
36
|
+
|
|
37
|
+
input_artifact(key)&.content_type
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
@scalar_data.to_h
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch(key, *args, &block)
|
|
45
|
+
value = self[key]
|
|
46
|
+
return value unless value.nil?
|
|
47
|
+
|
|
48
|
+
if args.any?
|
|
49
|
+
args.first
|
|
50
|
+
elsif block
|
|
51
|
+
block.call
|
|
52
|
+
else
|
|
53
|
+
raise KeyError, "key not found: #{key}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# The "input" artifact backing a file input, looked up once and cached
|
|
60
|
+
# (including a nil result, so a missing upload isn't re-queried).
|
|
61
|
+
def input_artifact(key)
|
|
62
|
+
key = key.to_sym
|
|
63
|
+
return @blob_cache[key] if @blob_cache.key?(key)
|
|
64
|
+
|
|
65
|
+
@blob_cache[key] = @run.artifacts.find_by(name: key.to_s, kind: "input")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def cast_value(value, type)
|
|
69
|
+
return value if value.nil?
|
|
70
|
+
|
|
71
|
+
case type
|
|
72
|
+
when :integer then value.to_i
|
|
73
|
+
when :float then value.to_f
|
|
74
|
+
when :boolean then ActiveModel::Type::Boolean.new.cast(value)
|
|
75
|
+
when :date then parse_date(value)
|
|
76
|
+
when :datetime then parse_datetime(value)
|
|
77
|
+
else value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse_date(value)
|
|
82
|
+
value.is_a?(Date) ? value : Date.parse(value.to_s)
|
|
83
|
+
rescue ArgumentError, TypeError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_datetime(value)
|
|
88
|
+
value.is_a?(Time) ? value : Time.zone.parse(value.to_s)
|
|
89
|
+
rescue ArgumentError, TypeError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class Task
|
|
3
|
+
include FormDsl
|
|
4
|
+
include ArtifactDsl
|
|
5
|
+
include JobDsl
|
|
6
|
+
include AboutDsl
|
|
7
|
+
include CallbacksDsl
|
|
8
|
+
|
|
9
|
+
attr_accessor :run
|
|
10
|
+
|
|
11
|
+
def self.inherited(subclass)
|
|
12
|
+
super
|
|
13
|
+
JobRegistry.register(subclass) if subclass.name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(run = nil)
|
|
17
|
+
@run = run
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Set by RunJob so a task can honour pause/cancel mid-work.
|
|
21
|
+
attr_writer :checkpoint_handler
|
|
22
|
+
|
|
23
|
+
# Honour a pending pause or cancel at this point.
|
|
24
|
+
#
|
|
25
|
+
# A collection task gets this for free between records. A callable task is
|
|
26
|
+
# a single Continuable step, so without an explicit call here a long-running
|
|
27
|
+
# `call` ignores Pause until it returns. Sprinkle it through the slow parts:
|
|
28
|
+
#
|
|
29
|
+
# def call
|
|
30
|
+
# Account.find_each do |account|
|
|
31
|
+
# checkpoint!
|
|
32
|
+
# account.recalculate!
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# When a stop is pending this does not return -- the job unwinds and the
|
|
37
|
+
# run lands in "paused" or "cancelled". Callable checkpoints also flush
|
|
38
|
+
# output and refresh the heartbeat; they do not persist a position in call.
|
|
39
|
+
# No-op outside a job.
|
|
40
|
+
def checkpoint!
|
|
41
|
+
@checkpoint_handler&.call
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Access typed form parameters
|
|
45
|
+
def params
|
|
46
|
+
@params_proxy ||= ParamsProxy.new(@run, self.class.form_inputs)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Access artifacts (read/write)
|
|
50
|
+
def artifacts
|
|
51
|
+
@artifacts_proxy ||= ArtifactsProxy.new(@run, self.class.artifact_definitions)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Maps the friendly :read/:write aliases onto Active Record's role names.
|
|
55
|
+
def self.normalize_database_role(role)
|
|
56
|
+
case role.to_sym
|
|
57
|
+
when :read, :reading then :reading
|
|
58
|
+
when :write, :writing then :writing
|
|
59
|
+
else role.to_sym
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Switch database role for the block.
|
|
64
|
+
# Usage: with_database_role(:read) { User.where(active: true).count }
|
|
65
|
+
#
|
|
66
|
+
# The block must force whatever it reads. Returning a lazy Relation from
|
|
67
|
+
# here does nothing -- the role is restored on the way out and the query
|
|
68
|
+
# runs later on the primary. To scan a `collection` against a replica,
|
|
69
|
+
# declare it instead so RunJob can hold the role open for the whole scan:
|
|
70
|
+
#
|
|
71
|
+
# job { database_role :reading }
|
|
72
|
+
def with_database_role(role, &block)
|
|
73
|
+
ActiveRecord::Base.connected_to(role: self.class.normalize_database_role(role), &block)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Override in subclass: return an ActiveRecord::Relation for batch processing
|
|
77
|
+
# def collection
|
|
78
|
+
# User.where(active: true)
|
|
79
|
+
# end
|
|
80
|
+
|
|
81
|
+
# Override in subclass: process a single record from collection
|
|
82
|
+
# def process(record)
|
|
83
|
+
# record.update!(...)
|
|
84
|
+
# end
|
|
85
|
+
|
|
86
|
+
# Override in subclass: for one-off tasks (no collection)
|
|
87
|
+
# def call
|
|
88
|
+
# SomeService.run
|
|
89
|
+
# end
|
|
90
|
+
|
|
91
|
+
def collection_task?
|
|
92
|
+
respond_to?(:collection) && respond_to?(:process)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def callable_task?
|
|
96
|
+
respond_to?(:call)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def task_type
|
|
100
|
+
if collection_task?
|
|
101
|
+
:collection
|
|
102
|
+
elsif callable_task?
|
|
103
|
+
:callable
|
|
104
|
+
else
|
|
105
|
+
raise "Task must define either collection+process or call"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class TextArtifact
|
|
3
|
+
attr_reader :record
|
|
4
|
+
|
|
5
|
+
delegate :to_s, to: :value
|
|
6
|
+
|
|
7
|
+
def initialize(record)
|
|
8
|
+
@record = record
|
|
9
|
+
# dup so `<<`/`puts` don't mutate the record's own attribute string in
|
|
10
|
+
# place (that would make dirty? always false and corrupt the record).
|
|
11
|
+
@value = (record.data_text || "").dup
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def value
|
|
15
|
+
@value
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Append text
|
|
19
|
+
def <<(text)
|
|
20
|
+
text = text.to_s
|
|
21
|
+
@record.check_buffer_size!(@value.bytesize + text.bytesize)
|
|
22
|
+
@value << text
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Append a line (adds newline)
|
|
27
|
+
def puts(text = "")
|
|
28
|
+
self << "#{text}\n"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Replace all text
|
|
32
|
+
def replace(text)
|
|
33
|
+
@record.check_buffer_size!(text.to_s.bytesize)
|
|
34
|
+
@value = text.to_s
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def save!
|
|
39
|
+
@record.data_text = @value
|
|
40
|
+
@record.refresh_metadata!
|
|
41
|
+
@record.save!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# True when buffered text differs from what's persisted. Lets the proxy
|
|
45
|
+
# auto-flush only artifacts that were actually written.
|
|
46
|
+
def dirty?
|
|
47
|
+
@value != (@record.data_text || "")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def blank?
|
|
51
|
+
@value.blank?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def present?
|
|
55
|
+
@value.present?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def length
|
|
59
|
+
@value.length
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def ==(other)
|
|
63
|
+
case other
|
|
64
|
+
when TextArtifact then @value == other.value
|
|
65
|
+
when String then @value == other
|
|
66
|
+
else false
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def inspect
|
|
71
|
+
"#<TextArtifact #{@value.truncate(60).inspect}>"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|