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,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
@@ -0,0 +1,3 @@
1
+ module MaintenanceOnSteroids
2
+ VERSION = "0.2.0"
3
+ end