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,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
|