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,56 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module MaintenanceOnSteroids
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Creates the migration and initializer for MaintenanceOnSteroids and mounts the engine."
12
+
13
+ def create_migration_file
14
+ migration_template(
15
+ "create_maintenance_on_steroids_tables.rb.erb",
16
+ "db/migrate/create_maintenance_on_steroids_tables.rb"
17
+ )
18
+ end
19
+
20
+ def create_initializer
21
+ template "initializer.rb", "config/initializers/maintenance_on_steroids.rb"
22
+ end
23
+
24
+ def create_maintenance_directory
25
+ empty_directory "app/maintenance"
26
+ create_file "app/maintenance/.keep"
27
+ end
28
+
29
+ def mount_engine
30
+ route 'mount MaintenanceOnSteroids::Engine, at: "/maintenance"'
31
+ end
32
+
33
+ def show_post_install
34
+ say ""
35
+ say "MaintenanceOnSteroids installed successfully!", :green
36
+ say ""
37
+ say "Next steps:"
38
+ say " 1. Run migrations: rails db:migrate"
39
+ say " 2. Configure access control in config/initializers/maintenance_on_steroids.rb"
40
+ say " 3. Create tasks in app/maintenance/"
41
+ say " 4. Visit /maintenance in your browser"
42
+ say ""
43
+ say "The dashboard is mounted WITHOUT access control until you configure it,", :yellow
44
+ say "and it can start any task against your database. In production the engine", :yellow
45
+ say "refuses to boot until one layer is set (or allow_insecure_dashboard = true).", :yellow
46
+ say ""
47
+ end
48
+
49
+ private
50
+
51
+ def migration_version
52
+ "[#{ActiveRecord::Migration.current_version}]"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,56 @@
1
+ class CreateMaintenanceOnSteroidsTables < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :maintenance_on_steroids_runs do |t|
4
+ t.string :task_class, null: false
5
+ t.string :status, null: false, default: "enqueued"
6
+ t.public_send(json_type, :params, default: {})
7
+ t.string :cursor
8
+ t.integer :progress_current, null: false, default: 0
9
+ t.integer :progress_total, null: false, default: 0
10
+ t.text :error_message
11
+ t.text :error_backtrace
12
+ t.string :active_job_id
13
+ t.string :execution_token
14
+ t.string :user_id
15
+ t.string :user_type
16
+ t.string :user_email
17
+
18
+ t.datetime :started_at
19
+ t.datetime :completed_at
20
+ t.timestamps
21
+ end
22
+
23
+ add_index :maintenance_on_steroids_runs, :task_class
24
+ add_index :maintenance_on_steroids_runs, :status
25
+ add_index :maintenance_on_steroids_runs, :created_at
26
+ add_index :maintenance_on_steroids_runs, [:task_class, :status]
27
+ add_index :maintenance_on_steroids_runs, [:task_class, :created_at]
28
+
29
+ create_table :maintenance_on_steroids_artifacts do |t|
30
+ t.references :run, null: false, foreign_key: { to_table: :maintenance_on_steroids_runs }
31
+
32
+ t.string :name, null: false
33
+ t.string :kind, null: false, default: "output"
34
+ t.string :artifact_type, null: false, default: "jsonb"
35
+ t.public_send(json_type, :data_jsonb)
36
+ t.binary :data_blob
37
+ t.text :data_text
38
+ t.string :file_name
39
+ t.string :content_type
40
+ t.public_send(json_type, :metadata, default: {})
41
+
42
+ t.timestamps
43
+ end
44
+
45
+ add_index :maintenance_on_steroids_artifacts, [:run_id, :name, :kind], unique: true, name: "idx_mos_artifacts_run_name_kind"
46
+ end
47
+
48
+ private
49
+
50
+ # jsonb on PostgreSQL: it is the indexable, canonical form, and the columns
51
+ # are named for it. Resolved at migrate time rather than generate time, so
52
+ # the same migration is correct wherever it runs. Everything else gets json.
53
+ def json_type
54
+ connection.adapter_name.match?(/postgres/i) ? :jsonb : :json
55
+ end
56
+ end
@@ -0,0 +1,42 @@
1
+ # Maintenance on Steroids
2
+ #
3
+ # The dashboard can start any task in app/maintenance/ against this database,
4
+ # so it needs access control before it goes anywhere near production. Configure
5
+ # at least one of the layers below -- in production the engine refuses to boot
6
+ # until you do.
7
+
8
+ MaintenanceOnSteroids.configure do |config|
9
+ # --- Access control (pick at least one) -------------------------------
10
+
11
+ # 1. HTTP Basic. Simplest option. The shipped password does NOT count as
12
+ # configured -- change it, and keep it out of version control.
13
+ # config.http_basic_authentication_enabled = true
14
+ # config.http_basic_authentication_user_name = "admin"
15
+ # config.http_basic_authentication_password = Rails.application.credentials.maintenance_password
16
+
17
+ # 2. Your app's own authentication. instance_exec'd in the controller, so
18
+ # Devise/Warden helpers are available.
19
+ # config.authentication = -> { authenticate_user! }
20
+
21
+ # 3. Authorization. Runs after the two above; returning false renders 403.
22
+ # An `authentication` hook alone only proves *who* the user is.
23
+ # config.verify_access_proc = ->(controller) { controller.current_user&.admin? }
24
+
25
+ # Already gated elsewhere (reverse proxy, VPN, middleware)? Say so
26
+ # explicitly instead of leaving the layers above empty.
27
+ # config.allow_insecure_dashboard = true
28
+
29
+ # --- Optional ---------------------------------------------------------
30
+
31
+ # Who triggered a run; must respond to #id (and optionally #email).
32
+ # config.current_user_resolver = -> { Current.user }
33
+
34
+ # How that user is displayed in the UI.
35
+ # config.user_display_formatter = ->(run) { run.user_email }
36
+
37
+ # Max size for file inputs uploaded when starting a run (read into memory).
38
+ # config.max_upload_size = 50 * 1024 * 1024
39
+
40
+ # Base controller for the engine's controllers, resolved at load time.
41
+ # config.parent_controller = "ApplicationController"
42
+ end
@@ -0,0 +1,17 @@
1
+ require "rails/generators"
2
+
3
+ module MaintenanceOnSteroids
4
+ module Generators
5
+ class JobGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates a new MaintenanceOnSteroids task in app/maintenance/"
9
+
10
+ class_option :collection, type: :boolean, default: true, desc: "Generate a collection-based task"
11
+
12
+ def create_task_file
13
+ template "job.rb.erb", "app/maintenance/#{file_name}.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,30 @@
1
+ class <%= class_name %> < MaintenanceOnSteroids::Task
2
+ about do
3
+ title "<%= class_name.titleize %>"
4
+ description "TODO: Add description"
5
+ end
6
+
7
+ <% if options[:collection] %>
8
+ # form do
9
+ # input :name, type: :string, required: true
10
+ # end
11
+
12
+ # artifact :result, type: :jsonb, default: {}
13
+
14
+ def collection
15
+ # Return an ActiveRecord::Relation
16
+ # Example: User.where(active: true)
17
+ raise NotImplementedError, "Define the collection to process"
18
+ end
19
+
20
+ def process(record)
21
+ # Process a single record from the collection
22
+ raise NotImplementedError, "Define how to process each record"
23
+ end
24
+ <% else %>
25
+ def call
26
+ # Implement your one-off task here
27
+ raise NotImplementedError, "Define the task logic"
28
+ end
29
+ <% end %>
30
+ end
@@ -0,0 +1,51 @@
1
+ module MaintenanceOnSteroids
2
+ module AboutDsl
3
+ extend ActiveSupport::Concern
4
+
5
+ class AboutConfig
6
+ attr_reader :title_text, :description_text, :owner_text
7
+
8
+ def initialize
9
+ @title_text = nil
10
+ @description_text = nil
11
+ @owner_text = nil
12
+ end
13
+
14
+ def title(value)
15
+ @title_text = value
16
+ end
17
+
18
+ def description(value)
19
+ @description_text = value
20
+ end
21
+
22
+ def owner(value)
23
+ @owner_text = value
24
+ end
25
+ end
26
+
27
+ class_methods do
28
+ def about(&block)
29
+ config = AboutConfig.new
30
+ config.instance_eval(&block)
31
+ @about_config = config
32
+ end
33
+
34
+ def about_config
35
+ @about_config || AboutConfig.new
36
+ end
37
+
38
+ def task_title
39
+ about_config.title_text || name.demodulize.titleize
40
+ end
41
+
42
+ def task_description
43
+ about_config.description_text
44
+ end
45
+
46
+ def task_owner
47
+ about_config.owner_text
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,82 @@
1
+ require "rack/mime"
2
+
3
+ module MaintenanceOnSteroids
4
+ module ArtifactDsl
5
+ extend ActiveSupport::Concern
6
+
7
+ class ArtifactDefinition
8
+ VALID_TYPES = %i[jsonb file text csv].freeze
9
+
10
+ # Names that would collide with real ArtifactsProxy methods, breaking
11
+ # method-style access (`artifacts.<name>`). Derived from the proxy's own
12
+ # public methods (minus the `[]`/`[]=` operators, which can't be artifact
13
+ # names) so the guard can't drift as proxy methods are added or renamed.
14
+ def self.reserved_names
15
+ # Memoized: ArtifactsProxy is loaded before any task class declares an
16
+ # artifact (see lib/maintenance_on_steroids.rb require order), so this
17
+ # reflects once per process instead of on every `artifact :name`.
18
+ @reserved_names ||= ArtifactsProxy.public_instance_methods(false) - %i([] []=)
19
+ end
20
+
21
+ attr_reader :name, :type, :default, :file_name, :label, :description, :headers, :content_type
22
+
23
+ def initialize(name, type: :jsonb, default: nil, file_name: nil, label: nil,
24
+ description: nil, headers: nil, content_type: nil)
25
+ @name = name.to_sym
26
+ @type = type.to_sym
27
+ unless VALID_TYPES.include?(@type)
28
+ raise ArgumentError,
29
+ "Unknown artifact type #{@type.inspect} for artifact #{@name.inspect}. " \
30
+ "Valid types: #{VALID_TYPES.join(', ')}"
31
+ end
32
+ if self.class.reserved_names.include?(@name)
33
+ raise ArgumentError,
34
+ "Artifact name #{@name.inspect} is reserved (conflicts with ArtifactsProxy##{@name}). " \
35
+ "Choose a different name."
36
+ end
37
+
38
+ @default = default
39
+ @file_name = file_name
40
+ @label = label || name.to_s.humanize
41
+ @description = description
42
+ @headers = headers
43
+ @content_type = content_type
44
+ end
45
+
46
+ # DB artifact_type / which data_* column the value is stored in.
47
+ # :file is a friendly alias for binary blob storage; :csv keeps a
48
+ # distinct artifact_type (its bytes live in data_blob) so the UI can
49
+ # offer a table preview and a correctly-typed download.
50
+ def storage_type
51
+ type == :file ? :blob : type
52
+ end
53
+
54
+ # Resolved MIME type for downloads: explicit content_type wins, then
55
+ # inference from the file name extension, then a per-type default.
56
+ def resolved_content_type(fallback_file_name = nil)
57
+ return @content_type if @content_type.present?
58
+
59
+ name = file_name || fallback_file_name
60
+ return "text/csv" if type == :csv && name.blank?
61
+ return nil if name.blank?
62
+
63
+ Rack::Mime.mime_type(::File.extname(name), nil)
64
+ end
65
+
66
+ def default_file_name
67
+ type == :csv ? "#{name}.csv" : nil
68
+ end
69
+ end
70
+
71
+ class_methods do
72
+ def artifact(name, **options)
73
+ @artifact_definitions ||= []
74
+ @artifact_definitions << ArtifactDefinition.new(name, **options)
75
+ end
76
+
77
+ def artifact_definitions
78
+ @artifact_definitions || []
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,205 @@
1
+ module MaintenanceOnSteroids
2
+ class ArtifactsProxy
3
+ def initialize(run, artifact_definitions)
4
+ @run = run
5
+ @definitions = artifact_definitions.index_by(&:name)
6
+ @cache = {}
7
+ end
8
+
9
+ def [](name)
10
+ name = name.to_sym
11
+ # key? rather than ||= : a :blob artifact wraps to the raw data_blob,
12
+ # which is nil until something is written, and ||= would re-run the
13
+ # find_by on every read -- once per record inside a collection task.
14
+ return @cache[name] if @cache.key?(name)
15
+
16
+ @cache[name] = load_artifact(name)
17
+ end
18
+
19
+ # Explicit, immediate persist of a full artifact value -- the primary write
20
+ # API. The type is taken from the artifact declaration, so one call covers
21
+ # every kind:
22
+ # artifacts.save(:json_data, { name: "Igor", age: 40 }) # jsonb
23
+ # artifacts.save(:export, rows) # csv (rows array or String)
24
+ # artifacts.save(:log, "done") # text
25
+ # Returns the stored artifact wrapper. No auto-flush involved -- the write
26
+ # happens now. (`artifacts[name] = value` is an alias.)
27
+ def save(name, value)
28
+ write_and_persist(name, value)
29
+ end
30
+
31
+ def []=(name, value)
32
+ write_and_persist(name, value)
33
+ value
34
+ end
35
+
36
+ def save!(name)
37
+ name = name.to_sym
38
+ cached = @cache[name]
39
+ cached.save! if cached.respond_to?(:save!)
40
+ end
41
+
42
+ def save_all!
43
+ @cache.each_key { |name| save!(name) }
44
+ end
45
+
46
+ # Persists only artifacts whose in-memory contents were actually written
47
+ # (dirty), so merely reading an artifact never creates a phantom row.
48
+ # Called automatically by RunJob at completion and on pause/cancel.
49
+ def flush!
50
+ failure = nil
51
+ @cache.each_pair do |name, cached|
52
+ next unless cached.respond_to?(:dirty?) && cached.dirty?
53
+
54
+ begin
55
+ cached.save!
56
+ rescue => e
57
+ failure ||= e
58
+ # One artifact failing to persist must not strand the others -- log
59
+ # the declared name (always available) and keep flushing the rest.
60
+ Rails.logger.error "[MaintenanceOnSteroids] Artifact flush error (#{name}): #{e.class}: #{e.message}"
61
+ end
62
+ end
63
+ raise failure if failure
64
+ end
65
+
66
+ # Drop uncommitted buffers after a record/checkpoint transaction failed.
67
+ def discard!
68
+ @cache.clear
69
+ end
70
+
71
+ # Method-style access for declared artifacts so call sites read naturally:
72
+ # artifacts.report << row # instead of artifacts[:report] << row
73
+ # artifacts.result["k"] = v
74
+ # artifacts.summary = "..."
75
+ def method_missing(method, *args)
76
+ key = method.to_s.chomp("=").to_sym
77
+ return super unless @definitions.key?(key)
78
+
79
+ if method.to_s.end_with?("=")
80
+ self[key] = args.first
81
+ else
82
+ self[key]
83
+ end
84
+ end
85
+
86
+ def respond_to_missing?(method, include_private = false)
87
+ @definitions.key?(method.to_s.chomp("=").to_sym) || super
88
+ end
89
+
90
+ private
91
+
92
+ # Shared write path for save / []=: write the value into the record,
93
+ # persist immediately, and cache the wrapper. Handles the concurrent-create
94
+ # race on the unique (run_id, name, kind) index.
95
+ def write_and_persist(name, value)
96
+ name = name.to_sym
97
+ definition = @definitions[name]
98
+ raise ArgumentError, "Unknown artifact: #{name}" unless definition
99
+
100
+ record = find_or_create_record(name, definition)
101
+ write_value(record, definition, value)
102
+ record.refresh_metadata!
103
+ begin
104
+ record.save!
105
+ rescue ActiveRecord::RecordNotUnique
106
+ # A concurrent writer created the row first (unique index on
107
+ # run_id/name/kind) -- write into the existing record instead.
108
+ record = @run.artifacts.find_by!(name: name.to_s, kind: "output")
109
+ record.worker_token = @run.worker_token
110
+ record.artifact_type = definition.storage_type.to_s
111
+ write_value(record, definition, value)
112
+ record.refresh_metadata!
113
+ record.save!
114
+ end
115
+ @cache[name] = wrap(record, definition)
116
+ end
117
+
118
+ def load_artifact(name)
119
+ definition = @definitions[name]
120
+ return nil unless definition
121
+
122
+ storage = definition.storage_type
123
+
124
+ # Reads must not INSERT: build the record lazily and persist it only
125
+ # on first save! / assignment. (update! on a new record saves it.)
126
+ record = @run.artifacts.find_by(name: name.to_s, kind: "output")
127
+ record ||= build_record(name, definition, storage)
128
+ record.worker_token = @run.worker_token
129
+
130
+ wrap(record, definition)
131
+ end
132
+
133
+ def build_record(name, definition, storage)
134
+ record = @run.artifacts.new(
135
+ name: name.to_s,
136
+ kind: "output",
137
+ artifact_type: storage.to_s,
138
+ data_jsonb: storage == :jsonb ? (definition.default || {}) : nil,
139
+ data_text: storage == :text ? (definition.default || "") : nil,
140
+ data_blob: %i[blob csv].include?(storage) ? definition.default : nil,
141
+ file_name: definition.file_name || definition.default_file_name
142
+ )
143
+ record.content_type = definition.resolved_content_type if storage == :csv
144
+ record
145
+ end
146
+
147
+ def find_or_create_record(name, definition)
148
+ @run.artifacts.find_or_initialize_by(name: name.to_s, kind: "output").tap do |r|
149
+ r.artifact_type = definition.storage_type.to_s
150
+ r.worker_token = @run.worker_token
151
+ end
152
+ end
153
+
154
+ def write_value(record, definition, value)
155
+ case definition.storage_type
156
+ when :jsonb
157
+ record.data_jsonb = value.is_a?(Hash) ? value : { value: value }
158
+ when :blob
159
+ if value.respond_to?(:read)
160
+ record.data_blob = value.read
161
+ record.file_name ||= value.original_filename if value.respond_to?(:original_filename)
162
+ record.content_type = value.content_type if value.respond_to?(:content_type)
163
+ else
164
+ record.data_blob = value
165
+ end
166
+ # Set file_name: explicit from DSL > already set > auto-generated default
167
+ record.file_name ||= definition.file_name || default_file_name
168
+ record.content_type ||= definition.resolved_content_type(record.file_name)
169
+ when :csv
170
+ record.data_blob = value.is_a?(String) ? value : render_csv(definition, value)
171
+ record.file_name ||= definition.file_name || definition.default_file_name
172
+ record.content_type ||= definition.resolved_content_type || "text/csv"
173
+ when :text
174
+ record.data_text = value.to_s
175
+ end
176
+ end
177
+
178
+ def render_csv(definition, rows)
179
+ require "csv"
180
+ CSV.generate do |csv|
181
+ csv << definition.headers if definition.headers
182
+ Array(rows).each { |row| csv << Array(row) }
183
+ end
184
+ end
185
+
186
+ def wrap(record, definition)
187
+ case definition.storage_type
188
+ when :jsonb
189
+ JsonbArtifact.new(record)
190
+ when :text
191
+ TextArtifact.new(record)
192
+ when :csv
193
+ CsvArtifact.new(record, headers: definition.headers)
194
+ when :blob
195
+ record.data_blob
196
+ end
197
+ end
198
+
199
+ def default_file_name
200
+ task_class = @run.task_class.to_s.underscore.gsub("/", "_")
201
+ timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
202
+ "#{task_class}_#{timestamp}"
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,61 @@
1
+ module MaintenanceOnSteroids
2
+ module CallbacksDsl
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include ActiveSupport::Callbacks
7
+
8
+ define_callbacks :start, :pause, :interrupt, :cancel, :complete, :error
9
+ end
10
+
11
+ class_methods do
12
+ def after_start(*methods, &block)
13
+ set_callback(:start, :after, *methods, &block)
14
+ end
15
+
16
+ def after_pause(*methods, &block)
17
+ set_callback(:pause, :after, *methods, &block)
18
+ end
19
+
20
+ def after_interrupt(*methods, &block)
21
+ set_callback(:interrupt, :after, *methods, &block)
22
+ end
23
+
24
+ def after_cancel(*methods, &block)
25
+ set_callback(:cancel, :after, *methods, &block)
26
+ end
27
+
28
+ def after_complete(*methods, &block)
29
+ set_callback(:complete, :after, *methods, &block)
30
+ end
31
+
32
+ def after_error(*methods, &block)
33
+ set_callback(:error, :after, *methods, &block)
34
+ end
35
+ end
36
+
37
+ def run_start_callbacks
38
+ run_callbacks(:start)
39
+ end
40
+
41
+ def run_pause_callbacks
42
+ run_callbacks(:pause)
43
+ end
44
+
45
+ def run_interrupt_callbacks
46
+ run_callbacks(:interrupt)
47
+ end
48
+
49
+ def run_cancel_callbacks
50
+ run_callbacks(:cancel)
51
+ end
52
+
53
+ def run_complete_callbacks
54
+ run_callbacks(:complete)
55
+ end
56
+
57
+ def run_error_callbacks
58
+ run_callbacks(:error)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,88 @@
1
+ require "csv"
2
+
3
+ module MaintenanceOnSteroids
4
+ # Append-oriented CSV artifact. Buffers rows in memory and renders them to
5
+ # the record's data_blob on save. Rows are plain arrays:
6
+ #
7
+ # artifacts[:report] << [user.id, user.name]
8
+ #
9
+ # Headers (when declared on the artifact) are written as the first line and
10
+ # transparently skipped when reloading an existing CSV (e.g. on resume).
11
+ class CsvArtifact
12
+ attr_reader :record, :headers, :rows
13
+
14
+ def initialize(record, headers: nil)
15
+ @record = record
16
+ @headers = headers
17
+ @rows = []
18
+ @dirty = false
19
+ @buffer_bytes = record.data_blob&.bytesize || (headers ? CSV.generate_line(headers).bytesize : 0)
20
+
21
+ if record.data_blob.present?
22
+ # A corrupted/partially-written blob must not abort a resume -- start
23
+ # from an empty buffer instead of raising through RunJob#perform.
24
+ begin
25
+ parsed = CSV.parse(record.data_blob)
26
+ if @headers && parsed.first&.map(&:to_s) == @headers.map(&:to_s)
27
+ parsed = parsed.drop(1)
28
+ end
29
+ @rows = parsed
30
+ rescue CSV::MalformedCSVError => e
31
+ # Resume must proceed, so start from an empty buffer -- but a later
32
+ # append+save will overwrite the unparseable blob, so log how many
33
+ # bytes are being discarded rather than losing them silently.
34
+ Rails.logger.error "[MaintenanceOnSteroids] CSV artifact reload failed " \
35
+ "(#{record.name}, discarding #{record.data_blob.bytesize} bytes): #{e.message}"
36
+ @rows = []
37
+ end
38
+ end
39
+ end
40
+
41
+ def <<(row)
42
+ row = Array(row)
43
+ bytes = CSV.generate_line(row).bytesize
44
+ @record.check_buffer_size!(@buffer_bytes + bytes)
45
+ @rows << row
46
+ @buffer_bytes += bytes
47
+ @dirty = true
48
+ self
49
+ end
50
+ alias add <<
51
+
52
+ def size
53
+ @rows.size
54
+ end
55
+
56
+ def blank?
57
+ @rows.empty?
58
+ end
59
+
60
+ def present?
61
+ !blank?
62
+ end
63
+
64
+ def to_csv
65
+ CSV.generate do |csv|
66
+ csv << @headers if @headers
67
+ @rows.each { |row| csv << row }
68
+ end
69
+ end
70
+
71
+ def save!
72
+ @record.data_blob = to_csv
73
+ @record.content_type ||= "text/csv"
74
+ @record.refresh_metadata!
75
+ @record.save!
76
+ @dirty = false
77
+ self
78
+ end
79
+
80
+ def dirty?
81
+ @dirty
82
+ end
83
+
84
+ def inspect
85
+ "#<MaintenanceOnSteroids::CsvArtifact rows=#{@rows.size}>"
86
+ end
87
+ end
88
+ end